feat(urls): configurable base domain via --base-domain #29947
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |