-
Notifications
You must be signed in to change notification settings - Fork 17
869 lines (783 loc) · 36.9 KB
/
Copy pathtest.yml
File metadata and controls
869 lines (783 loc) · 36.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
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