Skip to content

fix: bugsweep aug16 #29916

fix: bugsweep aug16

fix: bugsweep aug16 #29916

Workflow file for this run

name: Unity Test
on:
workflow_dispatch:
pull_request:
paths:
- 'Explorer/**'
# The rsp-drift job must run when the generator changes, or drift merges unnoticed.
- 'scripts/generate-ignore-warnings.sh'
types:
- opened
- reopened
- synchronize
- ready_for_review
- labeled
merge_group: {}
push: { branches: [dev] }
permissions:
contents: read
packages: write
checks: write
actions: read
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_LICENSE: ${{ secrets.UNITY_PERSONAL_LICENSE }}
UNITY_IMAGE_FLAVOR: linux-il2cpp
UNITY_IMAGE_VERSION: 3.2.2
GHCR_IMAGE_NAME: unityci-editor
INSPECT_REPORT_JSON_FILE_NAME: InspectCodeReport.json
WARNINGS_BASELINE_S3_KEY: '@dcl/unity-explorer/ci/warnings-baseline.json'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# The committed Explorer/Assets/*.rsp files must stay byte-identical to what
# generate-ignore-warnings.sh produces: on drift the preBuildScript rewrites them
# on every cloud build, and the Bee DAG rebuild (~4.5 min per build) silently returns.
rsp-drift:
name: rsp files match generator
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Regenerate rsp files and fail on drift
run: |
set -euo pipefail
bash scripts/generate-ignore-warnings.sh
# git status --porcelain (not `git diff`) so a newly added, still-untracked
# rsp file also counts as drift.
drift=$(git status --porcelain -- 'Explorer/Assets/*.rsp')
if [[ -n "$drift" ]]; then
echo "Committed rsp files differ from generate-ignore-warnings.sh output:"
echo "$drift"
git diff -- 'Explorer/Assets/*.rsp'
exit 1
fi
changes:
if: (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')) || (github.event.pull_request.draft == false) || (github.event.label.name == 'force-build') || (github.event.label.name == 'clean-build') || (github.event.label.name == 'force-lint')
name: Detect C# changes
runs-on: ubuntu-latest
outputs:
cs: ${{ steps.detect.outputs.cs }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: Detect changed C# files
id: detect
env:
EVENT: ${{ github.event_name }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
if [ "$EVENT" = "pull_request" ]; then
base=$(git merge-base "origin/$BASE_REF" "$HEAD_SHA" 2>/dev/null || true)
if [ -z "$base" ]; then
echo "merge-base with origin/$BASE_REF unavailable - linting to be safe."
echo "cs=true" >> "$GITHUB_OUTPUT"; exit 0
fi
range="$base $HEAD_SHA"
elif [ "$EVENT" = "push" ] && [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ] && git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
range="$BEFORE_SHA ${{ github.sha }}"
else
echo "Cannot determine a diff base for event '$EVENT' - linting to be safe."
echo "cs=true" >> "$GITHUB_OUTPUT"; exit 0
fi
if git diff --name-only $range -- '*.cs' | grep -q .; then
echo "C# files changed - lint will run."
echo "cs=true" >> "$GITHUB_OUTPUT"
else
echo "No C# files changed - skipping lint."
echo "cs=false" >> "$GITHUB_OUTPUT"
fi
lint:
needs: changes
if: needs.changes.outputs.cs == 'true' || github.event.label.name == 'force-lint'
name: Lint
runs-on: ubuntu-latest
# PRs from or into a release/hotfix branch may merge with an unchanged warning count
# (equality passes the ratchet below); the dev line still requires a strict reduction.
env:
IS_RELEASE_OR_HOTFIX: ${{ startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/') || startsWith(github.base_ref, 'release/') || startsWith(github.base_ref, 'hotfix/') }}
steps:
- name: Checkout CI actions
uses: actions/checkout@v6
with:
sparse-checkout: .github/actions
sparse-checkout-cone-mode: false
path: ci-actions
- name: Setup Docker disk
uses: ./ci-actions/.github/actions/setup-docker-disk
with:
keep-dotnet: 'true'
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
lfs: true
submodules: recursive
- name: Create LFS file list
run: git lfs ls-files -l | awk '{print $1}' | sort > .lfs-assets-id
- name: Restore LFS cache
uses: actions/cache@v4
id: lfs-cache-lint
with:
path: .git/lfs
key: ${{ runner.os }}-lfs-${{ hashFiles('.lfs-assets-id') }}
- name: Git LFS Pull
run: |
git lfs pull
git add .
git reset --hard
- uses: webfactory/ssh-agent@v0.8.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Add GitHub to the SSH known hosts file
run: |
mkdir -p -m 0700 /home/runner/.ssh
curl --silent https://api.github.com/meta | jq --raw-output '"github.com "+.ssh_keys[]' >> /home/runner/.ssh/known_hosts
chmod 600 /home/runner/.ssh/known_hosts
- name: Prepare SSH for Unity
run: |
rm -rf /tmp/ssh-unity
mkdir -p -m 0700 /tmp/ssh-unity
cp /home/runner/.ssh/known_hosts /tmp/ssh-unity/known_hosts
cp /home/runner/.ssh/key-* /tmp/ssh-unity/ 2>/dev/null || true
chmod 600 /tmp/ssh-unity/*
- name: Prepare Unity writable dirs
run: |
mkdir -p /tmp/unity-home/.local/share/unity3d
mkdir -p /tmp/unity-home/.cache/unity3d
mkdir -p /tmp/unity-home/.config/unity3d
chmod -R 777 /tmp/unity-home
- name: Read Unity version from ProjectVersion.txt
id: unity-version-lint
run: |
verFile="Explorer/ProjectSettings/ProjectVersion.txt"
ver=$(grep -o 'm_EditorVersion: [0-9a-f.]*' "$verFile" | cut -d' ' -f2)
echo "version=$ver" >> $GITHUB_OUTPUT
- name: Set image names
id: img-lint
run: |
version="${{ steps.unity-version-lint.outputs.version }}"
flavor="${{ env.UNITY_IMAGE_FLAVOR }}"
imgVer="${{ env.UNITY_IMAGE_VERSION }}"
tag="ubuntu-$version-$flavor-$imgVer"
owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
upstream="unityci/editor:$tag"
ghcr="ghcr.io/$owner/${{ env.GHCR_IMAGE_NAME }}:$tag"
echo "tag=$tag" >> $GITHUB_OUTPUT
echo "upstream=$upstream" >> $GITHUB_OUTPUT
echo "ghcr=$ghcr" >> $GITHUB_OUTPUT
- name: Log in to GitHub Container Registry
run: |
for i in 1 2 3; do
echo "${{ github.token }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin && exit 0
echo "GHCR login failed, retry $i/3"
sleep $((i * 10))
done
exit 1
- name: Restore Unity image from GHCR or seed from Docker Hub
run: |
set -o pipefail
ghcr="${{ steps.img-lint.outputs.ghcr }}"
upstream="${{ steps.img-lint.outputs.upstream }}"
if docker pull "$ghcr" 2>&1 | tee /tmp/ghcr-pull.log; then
exit 0
fi
if grep -qiE 'manifest unknown|not found' /tmp/ghcr-pull.log; then
echo "GHCR image missing, trying upstream..."
docker pull "$upstream"
docker tag "$upstream" "$ghcr"
docker push "$ghcr"
else
echo "GHCR pull failed, but not because the image is missing."
cat /tmp/ghcr-pull.log
exit 1
fi
# - name: Activate Unity license
# run: |
# docker run --rm \
# -v /tmp/unity-home/.local/share/unity3d:/root/.local/share/unity3d \
# -v /tmp/unity-home/.cache/unity3d:/root/.cache/unity3d \
# -v /tmp/unity-home/.config/unity3d:/root/.config/unity3d \
# -e UNITY_EMAIL="$UNITY_EMAIL" \
# -e UNITY_PASSWORD="$UNITY_PASSWORD" \
# -e UNITY_LICENSE="$UNITY_LICENSE" \
# ${{ steps.img-lint.outputs.ghcr }} \
# /bin/bash -lc 'source /opt/unity/activate.sh 2>/dev/null || true; unity-editor -batchmode -nographics -quit -logFile /dev/stdout'
# # C# Linting using JetBrains ReSharper CLI
# - name: Generate solution inside Unity Docker container
# run: |
# docker run --rm \
# -v "${{ github.workspace }}:/workspace" \
# -v $SSH_AUTH_SOCK:$SSH_AUTH_SOCK \
# -v /tmp/ssh-unity:/root/.ssh:ro \
# -v /tmp/unity-home/.local/share/unity3d:/root/.local/share/unity3d \
# -v /tmp/unity-home/.cache/unity3d:/root/.cache/unity3d \
# -v /tmp/unity-home/.config/unity3d:/root/.config/unity3d \
# -e SSH_AUTH_SOCK=$SSH_AUTH_SOCK \
# -e UNITY_EMAIL="$UNITY_EMAIL" \
# -e UNITY_PASSWORD="$UNITY_PASSWORD" \
# -e UNITY_LICENSE="$UNITY_LICENSE" \
# -w /workspace \
# ${{ steps.img-lint.outputs.ghcr }} \
# unity-editor \
# -batchmode \
# -nographics \
# -projectPath /workspace/Explorer \
# -executeMethod UnityEditor.SyncVS.SyncSolution \
# -quit \
# -logFile -
- name: Rewrite GitHub SSH URLs to HTTPS
run: |
git config --global url."https://x-access-token:${{ secrets.REPOS_READ_ONLY_TOKEN }}@github.com/".insteadOf "git@github.com:"
git config --global url."https://x-access-token:${{ secrets.REPOS_READ_ONLY_TOKEN }}@github.com/".insteadOf "ssh://git@github.com/"
- name: Generate solution with GameCI Builder
uses: game-ci/unity-builder@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_PERSONAL_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
GIT_PRIVATE_TOKEN: ${{ secrets.REPOS_READ_ONLY_TOKEN }}
# GIT_CONFIG_EXTENSIONS: true
with:
projectPath: Explorer
customImage: ${{ steps.img-lint.outputs.ghcr }}
targetPlatform: StandaloneLinux64
buildMethod: Packages.Rider.Editor.RiderScriptEditor.SyncSolution
- name: Download ReSharper Command Line Tools
run: bash scripts/lint/download-resharper.sh rsharp
# The ReSharper CLI needs a .NET runtime, which the Unity image lacks.
# Derive an image with the SDK (runtime + MSBuild for project-model evaluation)
- name: Build lint image with .NET SDK
id: lint-dotnet-img
run: |
base="${{ steps.img-lint.outputs.ghcr }}"
derived="${base}-dotnet"
cat > /tmp/Dockerfile.dotnet <<EOF
FROM $base
RUN apt-get update \
&& apt-get install -y --no-install-recommends dotnet-sdk-8.0 \
&& rm -rf /var/lib/apt/lists/*
EOF
docker build -t "$derived" -f /tmp/Dockerfile.dotnet /tmp
echo "image=$derived" >> "$GITHUB_OUTPUT"
# The solution generated by the GameCI step above bakes in absolute paths only valid there ('/opt/unity/...', and the
# workspace as '/github/workspace' — the exact path GameCI mounts it at, so the -v
# target below must match it). Running on the host leaves those references dangling
# and ReSharper resolves them heuristically per-run
- name: Run ReSharper InspectCode (inside Unity container)
run: |
docker run --rm \
-v "${{ github.workspace }}:/github/workspace" \
-w /github/workspace \
-e RSHARP_HOME=/github/workspace/rsharp \
-e HOME=/tmp \
"${{ steps.lint-dotnet-img.outputs.image }}" \
bash scripts/lint/run-inspectcode.sh Explorer/Explorer.sln "$INSPECT_REPORT_JSON_FILE_NAME"
# Filter false positives ('.CSharpErrors'/'.CppCompilerErrors', residual unresolved-ref noise) and count.
- name: Count warnings
id: warnings
uses: ./.github/actions/count-warnings
with:
report: ${{ env.INSPECT_REPORT_JSON_FILE_NAME }}
filtered_output: filtered.json
# With full in-container resolution, C# compiler-level diagnostics should be near
# zero (baseline: 11 stable Google.Protobuf Span-overload quirks). A spike means
# references stopped resolving (image change, GameCI mount path change) and the
# warning count is measuring environment breakage, not code - fail loudly instead
# of letting filter-warnings.sh discard the evidence. Only '.CSharpErrors' counts:
# '.CppCompilerErrors' is ReSharper's shader/C++ analyzer noise (~500 in .hlsl/
# .cginc files), unrelated to C# resolution health.
- name: Assert reference resolution is healthy
run: |
set -euo pipefail
errs=$(jq '[.runs[0].results[] | select(.ruleId == ".CSharpErrors")] | length' "$INSPECT_REPORT_JSON_FILE_NAME")
echo "C# compiler-error diagnostics in report: $errs (filtered warning count: ${{ steps.warnings.outputs.count }})"
if [ "$errs" -gt 20 ]; then
echo "::error::$errs C# compiler-level errors in the InspectCode report - references are not resolving, the warning count cannot be trusted."
exit 1
fi
# Keep the raw and filtered reports inspectable for investigation purposes in a case of failure from "Assert reference resolution is healthy"
- name: Upload InspectCode reports
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: inspect-code-reports
path: |
${{ env.INSPECT_REPORT_JSON_FILE_NAME }}
filtered.json
if-no-files-found: ignore
retention-days: 3
- name: Read warnings baseline from S3
id: baseline
env:
AWS_MAX_ATTEMPTS: 3
AWS_RETRY_MODE: standard
AWS_ACCESS_KEY_ID: ${{ secrets.EXPLORER_TEAM_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.EXPLORER_TEAM_AWS_SECRET_ACCESS_KEY }}
EXPLORER_TEAM_S3_BUCKET: ${{ secrets.EXPLORER_TEAM_S3_BUCKET }}
run: |
# Read directly from s3 to avoid cache stales
baseline=$(aws s3 cp "s3://$EXPLORER_TEAM_S3_BUCKET/${{ env.WARNINGS_BASELINE_S3_KEY }}" - 2>/dev/null | jq -r '.count' 2>/dev/null || echo "")
echo "baseline=$baseline" >> "$GITHUB_OUTPUT"
echo "Baseline warnings: ${baseline:-<none>}"
# Hard gate: a dev-targeted PR may only merge if it strictly reduces the warning count.
# Release/hotfix PRs skip this gate — each commit already passed it when merging into dev.
# No baseline yet -> pass (the first dev push seeds it). Add the 'no-warning-ratchet'
# label to bypass on a dev PR that legitimately cannot touch warnings.
- name: Enforce warning reduction (PR)
if: github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'dev'
env:
COUNT: ${{ steps.warnings.outputs.count }}
BASELINE: ${{ steps.baseline.outputs.baseline }}
BYPASS: ${{ contains(github.event.pull_request.labels.*.name, 'no-warning-ratchet') }}
run: |
if [ -z "$BASELINE" ]; then
echo "No baseline established yet - passing. The first push to dev will seed it."
exit 0
fi
if [ "$COUNT" -lt "$BASELINE" ]; then
echo "Warnings reduced: $COUNT < $BASELINE"
exit 0
fi
if [ "$IS_RELEASE_OR_HOTFIX" = "true" ] && [ "$COUNT" -eq "$BASELINE" ]; then
echo "Warnings unchanged ($COUNT == $BASELINE) on a release/hotfix branch - passing."
exit 0
fi
if [ "$BYPASS" = "true" ]; then
echo "::warning::Warnings not reduced ($COUNT >= $BASELINE) but 'no-warning-ratchet' label present - bypassing."
exit 0
fi
need=$((COUNT - BASELINE + 1))
if [ "$need" -eq 1 ]; then noun="warning"; else noun="warnings"; fi
echo "::error::Compilation warnings ($COUNT) must be strictly LESS than the baseline ($BASELINE). Remove at least $need $noun to merge (or apply the 'no-warning-ratchet' label)."
exit 1
# Lower the baseline on merge to dev. Only ever decreases it.
- name: Update warnings baseline on S3 (dev)
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
env:
COUNT: ${{ steps.warnings.outputs.count }}
AWS_MAX_ATTEMPTS: 3
AWS_RETRY_MODE: standard
AWS_ACCESS_KEY_ID: ${{ secrets.EXPLORER_TEAM_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.EXPLORER_TEAM_AWS_SECRET_ACCESS_KEY }}
EXPLORER_TEAM_S3_BUCKET: ${{ secrets.EXPLORER_TEAM_S3_BUCKET }}
run: |
set -euo pipefail
# Read directly from s3 to avoid cache stales
cur=$(aws s3 cp "s3://$EXPLORER_TEAM_S3_BUCKET/${{ env.WARNINGS_BASELINE_S3_KEY }}" - 2>/dev/null | jq -r '.count' 2>/dev/null || echo "")
if [ -n "$cur" ] && [ "$COUNT" -ge "$cur" ]; then
echo "No update: current count ($COUNT) is not less than existing baseline ($cur)."
exit 0
fi
echo "Updating baseline: ${cur:-<none>} => $COUNT"
jq -n --argjson c "$COUNT" \
'{count: $c, comment: "read and modified by CI/CD of explorer repo"}' > baseline.json
aws s3 cp baseline.json "s3://$EXPLORER_TEAM_S3_BUCKET/${{ env.WARNINGS_BASELINE_S3_KEY }}" \
--content-type application/json --cache-control no-cache
# Warnings/errors located in files this PR changed — surfaced in the PR comment.
- name: Collect PR-file warnings
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
id: pr-findings
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
MAX_FINDINGS: 50 # max rows listed in the PR comment; change here
run: |
set -euo pipefail
echo '[]' > pr-findings.json
echo '[]' > droppable.json
echo "total=0" >> "$GITHUB_OUTPUT"
[ -f filtered.json ] || exit 0
base=$(git merge-base "origin/$BASE_REF" "$HEAD_SHA" 2>/dev/null || true)
[ -n "$base" ] || { echo "No merge-base - skipping PR-file findings."; exit 0; }
# repo-relative paths -> project-relative (strip leading 'Explorer/')
git diff --name-only "$base" "$HEAD_SHA" -- '*.cs' \
| sed 's#^Explorer/##' | sort -u > changed-files.txt
jq --rawfile changed changed-files.txt '
($changed | split("\n") | map(select(length > 0))) as $files
| map({
file: (.locations[0].physicalLocation.artifactLocation.uri // ""),
line: (.locations[0].physicalLocation.region.startLine // 0),
rule: (.ruleId // ""),
level: (.level // "warning"),
message: (.message.text // "")
})
| map(select(.file as $f | $files | index($f) != null))
' filtered.json > pr-findings-all.json
total=$(jq length pr-findings-all.json)
jq --argjson max "$MAX_FINDINGS" '.[:$max]' pr-findings-all.json > pr-findings.json # cap comment size
echo "total=$total" >> "$GITHUB_OUTPUT"
echo "PR-file findings: $total (listing up to $MAX_FINDINGS)"
# Also surface up to MAX_FINDINGS easy-to-remove warnings from anywhere in the
# codebase, so a blocked PR whose own files are already clean still has concrete
# targets. Redundant/unused findings are listed first (safest mechanical removals).
jq --argjson max "$MAX_FINDINGS" '
[ .[] | {
file: (.locations[0].physicalLocation.artifactLocation.uri // ""),
line: (.locations[0].physicalLocation.region.startLine // 0),
rule: (.ruleId // ""),
message: (.message.text // "")
} ]
| sort_by(if (.rule | test("Redundant|Unused|Unaccessed")) then 0 else 1 end)
| .[:$max]
' filtered.json > droppable.json
# Hand the numbers to the trusted workflow_run companion that posts the PR comment.
# Runs even when the gate above failed (!cancelled), so a failing run still refreshes the comment
# instead of leaving a stale message.
- name: Write warning result for PR comment
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
env:
COUNT: ${{ steps.warnings.outputs.count }}
BASELINE: ${{ steps.baseline.outputs.baseline }}
FINDINGS_TOTAL: ${{ steps.pr-findings.outputs.total || 0 }}
run: |
if [ -z "$COUNT" ]; then
echo "No warning count computed (lint produced no result) - skipping comment payload."
exit 0
fi
jq -n \
--argjson pr "${{ github.event.pull_request.number }}" \
--argjson count "$COUNT" \
--arg baseline "$BASELINE" \
--argjson allow_equal "$IS_RELEASE_OR_HOTFIX" \
--slurpfile findings pr-findings.json \
--argjson findings_total "$FINDINGS_TOTAL" \
--slurpfile droppable droppable.json \
'{pr: $pr, count: $count, baseline: (if $baseline == "" then null else ($baseline | tonumber) end), allow_equal: $allow_equal, pr_findings: ($findings[0] // []), pr_findings_total: $findings_total, droppable: ($droppable[0] // [])}' \
> warning-result.json
cat warning-result.json
- name: Upload warning result
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@v4
with:
name: warning-result
path: warning-result.json
if-no-files-found: ignore
- name: Upload lint reports
uses: actions/upload-artifact@v4
with:
name: csharp-lint-reports
path: |
${{ env.INSPECT_REPORT_JSON_FILE_NAME }}
filtered.json
# CodeStyleReport.xml
# END: C# Linting using JetBrains ReSharper CLI
test:
# Skip when PR has 'perf_test' label (performance tests run in separate workflow)
if: |
!contains(github.event.pull_request.labels.*.name, 'perf_test') && (
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event.pull_request.draft == false) ||
(github.event.label.name == 'force-build') ||
(github.event.label.name == 'clean-build')
)
name: Test (${{ matrix.testMode }})
runs-on: ubuntu-latest
strategy:
matrix:
testMode: [playmode, editmode]
steps:
- name: Checkout CI actions
uses: actions/checkout@v6
with:
sparse-checkout: .github/actions
sparse-checkout-cone-mode: false
path: ci-actions
- name: Setup Docker disk
uses: ./ci-actions/.github/actions/setup-docker-disk
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
lfs: true
submodules: recursive
- name: Create LFS file list
run: git lfs ls-files -l | awk '{print $1}' | sort > .lfs-assets-id
- name: Restore LFS cache
uses: actions/cache@v5
id: lfs-cache
with:
path: .git/lfs
key: ${{ runner.os }}-lfs-${{ hashFiles('.lfs-assets-id') }}
- name: Git LFS Pull
run: |
git lfs pull
git add .
git reset --hard
- uses: webfactory/ssh-agent@v0.8.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: 'Add GitHub to the SSH known hosts file'
run: |
mkdir -p -m 0700 ~/.ssh
echo 'github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl' >> ~/.ssh/known_hosts
echo 'github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=' >> ~/.ssh/known_hosts
echo 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZKAc/NKgzBh2bFNFMR0mVmYeeNR1cb+gZXH6N1gjEHGo2J3qjVg9rBJ0aM5st/22iqkAl7K6d9jMTi6COwJKqFHGmn7gwK+FBnfaYDwdOB8JqGz9eDNi2SegGaKie1jURLEuRPkLqlJrJ3AEdC5v7ibREzMQPGiHlIC5bE0GD/3mPN1Kul4a4EZhIC5RFxewRqDaK7be0wYMbSW7JzfLDMVtRAkKO5GjaKKv4RzGmrgVApFI/j6jJ0sTJZVgmmqODx0P+RcAfGV0RBCjMEdOPj4yr5ObHGjYCdaFEhXGTIlSGeFSEAFGFweZb9t80b8ABbeBRwx/dqI94aaFkMRkzT8VJ9B4yQvbWO7HP64Lxk6rab5Ygwypbcghmms/ATrc0CVh0zGw36VTzV2eBwboRa7G0sxgBudGrJ4BAOI/JrF4BnWcG5fvQewJ1Uc=' >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Restore Library cache
uses: actions/cache@v5
with:
path: Explorer/Library
key: Library-Explorer-Ubuntu-${{ hashFiles('Explorer/Packages/packages-lock.json', 'Explorer/ProjectSettings/ProjectVersion.txt') }}
restore-keys: Library-Explorer-Ubuntu-
# --- Detect Unity version from the project ---
- name: Read Unity version from ProjectVersion.txt
id: unity-version
run: |
verFile="Explorer/ProjectSettings/ProjectVersion.txt"
if [ ! -f "$verFile" ]; then
echo "File not found: $verFile"
exit 1
fi
ver=$(grep -o 'm_EditorVersion: [0-9a-f.]*' "$verFile" | cut -d' ' -f2)
if [ -z "$ver" ]; then
echo "Could not parse Unity version from $verFile"
exit 1
fi
echo "version=$ver" >> $GITHUB_OUTPUT
echo "Detected Unity version: $ver"
# --- Build image references (GHCR + upstream) ---
- name: Set image names
id: img
run: |
version="${{ steps.unity-version.outputs.version }}"
flavor="${{ env.UNITY_IMAGE_FLAVOR }}"
imgVer="${{ env.UNITY_IMAGE_VERSION }}"
# ubuntu-<unityVersion>-<flavor>-<gameciVersion>
tag="ubuntu-$version-$flavor-$imgVer"
owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
upstream="unityci/editor:$tag"
ghcr="ghcr.io/$owner/${{ env.GHCR_IMAGE_NAME }}:$tag"
echo "tag=$tag" >> $GITHUB_OUTPUT
echo "upstream=$upstream" >> $GITHUB_OUTPUT
echo "ghcr=$ghcr" >> $GITHUB_OUTPUT
echo "Upstream image: $upstream"
echo "GHCR image: $ghcr"
# --- Log into GHCR ---
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
# --- Restore Unity image from GHCR (fast path) ---
- name: Restore Unity image from GHCR
id: restore-image
continue-on-error: true
run: |
docker pull "${{ steps.img.outputs.ghcr }}"
# --- Seed GHCR from Docker Hub if missing (one-time per tag) ---
- name: Download from Docker Hub and upload to GHCR (if missing)
if: steps.restore-image.outcome == 'failure'
run: |
upstream="${{ steps.img.outputs.upstream }}"
ghcr="${{ steps.img.outputs.ghcr }}"
echo "GHCR miss. Pulling upstream: $upstream"
docker pull $upstream
docker tag $upstream $ghcr
docker push $ghcr
echo "Seeded GHCR: $ghcr"
- name: Install ripgrep for edit mode tests
if: matrix.testMode == 'editmode'
id: rg-img
run: |
base="${{ steps.img.outputs.ghcr }}"
derived="${base}-rg"
cat > /tmp/Dockerfile.rg <<EOF
FROM $base
RUN apt-get update \
&& apt-get install -y --no-install-recommends ripgrep \
&& rm -rf /var/lib/apt/lists/*
EOF
docker build -t "$derived" -f /tmp/Dockerfile.rg /tmp
echo "image=$derived" >> $GITHUB_OUTPUT
echo "Built derived image with ripgrep: $derived"
# game-ci pipes the editor output to artifacts/<mode>.log, so a deadlocked
# editor keeps the step console silent and burns the full 80-minute step
# timeout (run 29613116640 attempt 1: editmode console silent for 70 min
# after "Testing in editmode" until manually cancelled). The watchdog polls
# that log from the host and kills the Unity container when it stops
# growing, failing the step within ~15 min instead. It survives across
# steps as a background process and is stopped by the step after the
# runner; if the runner dies without cleanup it self-expires (MAX_LIFETIME).
- name: Start stale-log watchdog
env:
UNITY_IMAGE: ${{ matrix.testMode == 'editmode' && steps.rg-img.outputs.image || steps.img.outputs.ghcr }}
TEST_MODE: ${{ matrix.testMode }}
run: |
nohup bash scripts/ci/test-stale-log-watchdog.sh \
"artifacts/$TEST_MODE.log" "$UNITY_IMAGE" watchdog-status.json \
> watchdog-output.log 2>&1 &
echo $! > watchdog.pid
echo "Stale-log watchdog started (pid $(cat watchdog.pid)) for artifacts/$TEST_MODE.log"
# Configure test runner
- uses: game-ci/unity-test-runner@v4.3.1
id: testRunner
timeout-minutes: 80
continue-on-error: true
env:
SHELL: /bin/bash
with:
projectPath: Explorer
testMode: ${{ matrix.testMode }}
sshAgent: ${{ env.SSH_AUTH_SOCK }}
customImage: ${{ matrix.testMode == 'editmode' && steps.rg-img.outputs.image || steps.img.outputs.ghcr }}
customParameters: -buildTarget StandaloneLinux64 -testCategory "!Performance" -burst-disable-compilation -accept-apiupdate
githubToken: ${{ env.GITHUB_TOKEN }}
# Fails the job with an explicit cause when the watchdog killed the
# container — without this the only signals are the confusing follow-on
# errors ("Input required and not supplied: path", "No test report files
# were found") seen in run 29613116640.
- name: Stop stale-log watchdog and fail on stall
if: always()
env:
TEST_MODE: ${{ matrix.testMode }}
RUNNER_OUTCOME: ${{ steps.testRunner.outcome }}
run: |
if [ -f watchdog.pid ]; then
kill "$(cat watchdog.pid)" 2>/dev/null || true
fi
echo "::group::Watchdog output"
cat watchdog-output.log 2>/dev/null || echo "(no watchdog output)"
echo "::endgroup::"
# An unarmed watchdog gave this job no hang protection at all, usually
# because the editor log moved; don't let that pass unremarked. Runs
# that never reached a verdict (cancelled by cancel-in-progress, or
# skipped) ended before the editor wrote anything, so there is no
# drift to report there.
if { [ "$RUNNER_OUTCOME" = success ] || [ "$RUNNER_OUTCOME" = failure ]; } \
&& ! grep -q "stall detection armed" watchdog-output.log 2>/dev/null; then
echo "::warning::Stale-log watchdog never armed - artifacts/$TEST_MODE.log never grew, so a hang would still have run to the step timeout. Check that game-ci still writes the editor log there."
fi
if [ -f watchdog-status.json ]; then
stalled=$(jq -r '.stalledSeconds' watchdog-status.json 2>/dev/null || echo unknown)
echo "::error::Unity $TEST_MODE tests hung: artifacts/$TEST_MODE.log did not grow for ${stalled}s, so the stale-log watchdog killed the Unity container. See 'Dump Unity Editor log on failure' below for the log tail."
exit 1
fi
- name: Dump Unity Editor log on failure
if: always() && steps.testRunner.outcome != 'success'
env:
ARTIFACTS_PATH: ${{ steps.testRunner.outputs.artifactsPath }}
TEST_MODE: ${{ matrix.testMode }}
run: |
echo "::group::Unity Editor log (last 200 lines)"
# game-ci runs the test editor with `-logFile <artifactsPath>/<mode>.log`,
# where artifactsPath resolves to "artifacts" at the workspace root
# (NOT Explorer/artifacts — projectPath does not prefix it). Prefer the
# action's own resolved output (matches the Report/Upload steps), but
# fall back to the literal path because on a hard timeout kill the action
# is killed before exporting its outputs (the Report step then fails with
# "Input required and not supplied: path"). Values are passed via env
# rather than interpolated into the shell directly.
LOG=""
for candidate in \
"$ARTIFACTS_PATH/$TEST_MODE.log" \
"artifacts/$TEST_MODE.log"; do
if [ -n "$candidate" ] && [ -f "$candidate" ]; then
LOG="$candidate"
break
fi
done
if [ -n "$LOG" ]; then
echo "Showing last 200 lines of: $LOG"
tail -200 "$LOG"
else
echo "No $TEST_MODE.log found in known locations."
echo "Searching workspace for any Unity logs (depth-bounded to skip Library/):"
matches=$(find . -maxdepth 3 -name "*.log" 2>/dev/null || true)
if [ -n "$matches" ]; then
echo "$matches" | head -50
else
echo "No .log files found"
fi
fi
echo "::endgroup::"
- name: Report test results
if: always()
uses: dorny/test-reporter@v2.1.1
with:
name: Unity Tests (${{ matrix.testMode }})
path: ${{ steps.testRunner.outputs.artifactsPath }}/*.xml
reporter: dotnet-nunit
fail-on-error: true
fail-on-empty: true
use-actions-summary: true
list-suites: failed
list-tests: failed
# Extracts just the failed test-case fullnames from the NUnit XML results, so the
# trusted pr-comment-test-failures.yml companion (workflow_run) can post a small,
# copy-pasteable PR comment - a dev can grab a name and re-run it locally instead of
# reading the full report.
- name: Extract failed test names
if: always() && github.event_name == 'pull_request'
env:
ARTIFACTS_PATH: ${{ steps.testRunner.outputs.artifactsPath }}
TEST_MODE: ${{ matrix.testMode }}
run: |
python3 - <<'PY'
import glob, json, os, xml.etree.ElementTree as ET
artifacts_path = os.environ["ARTIFACTS_PATH"]
test_mode = os.environ["TEST_MODE"]
xml_files = glob.glob(f"{artifacts_path}/*.xml")
total = 0
passed = 0
failed = []
for path in xml_files:
try:
root = ET.parse(path).getroot()
except ET.ParseError:
continue
for test_case in root.iter("test-case"):
case_result = test_case.get("result")
if case_result is None:
continue
total += 1
if case_result == "Passed":
passed += 1
elif case_result == "Failed":
failed.append(test_case.get("fullname") or test_case.get("name"))
result = {
"hasResults": len(xml_files) > 0,
"total": total,
"passed": passed,
"failed": sorted(set(failed)),
}
with open(f"failed-tests-{test_mode}.json", "w") as f:
json.dump(result, f)
print(f"[{test_mode}] xml files: {len(xml_files)}, total: {total}, passed: {passed}, failed: {len(result['failed'])}")
PY
- name: Upload failed test names
if: always() && github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
with:
name: failed-tests-${{ matrix.testMode }}
path: failed-tests-${{ matrix.testMode }}.json
if-no-files-found: ignore
# Upload artifact
- uses: actions/upload-artifact@v6
if: always()
with:
name: Test results (${{ matrix.testMode }})
path: ${{ steps.testRunner.outputs.artifactsPath }}
if-no-files-found: error
watchdog:
runs-on: ubuntu-latest
needs: [lint, test]
if: |
needs.lint.result == 'failure' ||
needs.test.result == 'failure'
steps:
- run: exit 1