Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 182 additions & 1 deletion .github/workflows/engine-smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,187 @@ jobs:

echo "Shipped binary verified end to end against a real container"

# ─── Unity — the Mirror#4128 abort message, end to end ─────────
# The incident: a container aborted Unity, docker led its own stderr with the
# benign "Unable to find image '...' locally" pre-pull notice, and the CLI's
# final error appended that stderr under an "Original error:" heading. The
# message therefore read as an editor failing to load a Unity version, while
# Unity's real reason - a genuine script compile error - sat two lines above
# it. Two Mirror maintainers went hunting for a version problem (see #288).
#
# src/model/docker.test.ts already asserts the corrected message, but it
# hard-codes BOTH streams onto its fake error, so it stays green if Docker.run
# reads the wrong stream or System.run ever folds them together. That plumbing
# is precisely what broke. Nothing in CI ran a real `docker run` through it, so
# this job does, and grades what the user is actually shown
# (scripts/assert-unity-abort-message.sh, itself tested by its sibling in
# tests.yml).
#
# Both CLI routes are exercised, because they reach stdout differently:
# build.sh runs the editor with `-logfile /dev/stdout`, whereas test.sh writes
# the log to a file and cats it after capturing the exit code. The stub below
# honours whichever spelling it is handed.
unity-abort-message:
name: Unity abort message (Mirror#4128)
needs: changes
if: needs.changes.outputs.core == 'true' || needs.changes.outputs.unity == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

# Same stub shape as unity-build's above, with two differences: it fails
# the way the incident failed, and it puts each half on the stream reality
# puts it on.
#
# Docker cannot supply the stderr half itself here: this image is built
# locally a few lines up, so there is nothing for docker to pull and
# docker writes nothing. The container emits that text on the stream docker
# would have used, leading with the same benign "not cached locally" line.
- name: Build aborting-editor stub
run: |
mkdir -p /tmp/unity-abort-stub-ctx
cat > /tmp/unity-abort-stub-ctx/unity-editor <<'SCRIPT'
#!/bin/bash
set -euo pipefail

# Activation has to succeed, or runsteps.sh exits before ever reaching
# the build/test step. Same licence line unity-build's stub prints,
# which activate.sh greps for.
if printf '%s\n' "$*" | grep -q -- '-manualLicenseFile'; then
echo "LICENSE SYSTEM [CI Stub] Next license update check is after 2099-01-01T00:00:00"
exit 0
fi

# Where Unity was told to write its log. build.sh passes
# `-logfile /dev/stdout`; test.sh passes `-logFile <artifacts>/x.log`
# and cats it afterwards. Honouring both keeps each route's abort
# reason on the stream that route really puts it on.
LOG_DEST=""
while [ "$#" -gt 0 ]; do
case "$1" in
-logFile|-logfile)
LOG_DEST="${2:-}"
shift 2
;;
*)
shift
;;
esac
done

# Docker's own stderr in the incident: harmless status output whose
# first line names an image it is about to pull. Quoted back under an
# "Original error:" heading, it read as an editor failing to load that
# version.
{
echo "Unable to find image 'game-ci/unity-abort-stub:latest' locally"
echo "latest: Pulling from game-ci/unity-abort-stub"
} >&2

# Unity's own reason, on Unity's own stream. The grader keys on this
# exact pairing - and on the two stderr lines above - to prove the
# incident was really reproduced rather than passing its absence
# checks vacuously.
print_abort() {
echo "Aborting batchmode due to failure:"
echo "Scripts have compiler errors."
}

if [ -z "$LOG_DEST" ] || [ "$LOG_DEST" = "/dev/stdout" ]; then
print_abort
else
mkdir -p "$(dirname "$LOG_DEST")"
print_abort > "$LOG_DEST"
fi
exit 1
SCRIPT
chmod +x /tmp/unity-abort-stub-ctx/unity-editor

cat > /tmp/unity-abort-stub-ctx/Dockerfile <<'DOCKERFILE'
FROM alpine:3.19
RUN apk add --no-cache bash coreutils git
COPY unity-editor /usr/local/bin/unity-editor
RUN chmod +x /usr/local/bin/unity-editor
WORKDIR /github/workspace
DOCKERFILE

docker build -t game-ci/unity-abort-stub:latest -f /tmp/unity-abort-stub-ctx/Dockerfile /tmp/unity-abort-stub-ctx

# No --allowDirtyBuild here, unlike unity-build's second run: versioning's
# dirty check is a yargs middleware registered inside
# VersioningOptions.configure, which only `game-ci build` calls - `test`
# never runs it. (It is also not an option there, and the CLI parses
# strictly, so passing it would fail the invocation outright.)
- name: Run the aborting build and grade the message
# Declared as env rather than interpolated into the script body below:
# ${{ }} expands into the source before bash ever sees it, which makes a
# workflow_dispatch input shell code instead of data.
env:
UNITY_VERSION: ${{ github.event.inputs.unity-version || '2019.4.40f1' }}
run: |
set -uo pipefail
LOG=/tmp/unity-abort-build.log

# Captured to a FILE, never piped through tee: writes to a pipe are
# asynchronous, so the CLI's process.exit(1) can truncate the very
# message under test. File writes are synchronous.
if bun run src/index.ts build test-project \
--engineVersion "${UNITY_VERSION}" \
--targetPlatform StandaloneLinux64 \
--customImage game-ci/unity-abort-stub:latest \
--unityLicense ci-stub-license \
> "$LOG" 2>&1; then
echo "::error::the aborting stub build was expected to fail, but exited 0"
cat "$LOG"
exit 1
fi

cat "$LOG"
bash scripts/assert-unity-abort-message.sh "$LOG"

# The incident was `game-ci test`, not `build`, and test.sh reaches stdout
# by cat-ing the log file rather than via -logfile /dev/stdout. Same
# Docker.run catch either way, but a future change to that cat's position
# relative to the exit-code capture could lose the reason while the build
# step above stayed green.
- name: Run the aborting test and grade the message
# Same reason as the build step above.
env:
UNITY_VERSION: ${{ github.event.inputs.unity-version || '2019.4.40f1' }}
run: |
set -uo pipefail
LOG=/tmp/unity-abort-test.log

# --docker is what selects the container flow; without it this would
# run the experimental `unity test` CLI path instead.
if bun run src/index.ts test test-project \
--docker \
--engineVersion "${UNITY_VERSION}" \
--targetPlatform StandaloneLinux64 \
--testPlatforms editmode \
--coverageEnabled false \
--customImage game-ci/unity-abort-stub:latest \
--unityLicense ci-stub-license \
> "$LOG" 2>&1; then
echo "::error::the aborting stub test run was expected to fail, but exited 0"
cat "$LOG"
exit 1
fi

cat "$LOG"
bash scripts/assert-unity-abort-message.sh "$LOG"

# ─── Godot — detect + build using third-party image ────────────
godot-build:
name: Godot build
Expand Down Expand Up @@ -503,7 +684,7 @@ jobs:
# blocks every path-scoped PR, which would defeat the filtering.
smoke-gate:
name: Engine smoke gate
needs: [unity-build, godot-build, unreal-build, protocol-tests]
needs: [unity-build, unity-abort-message, godot-build, unreal-build, protocol-tests]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
Expand Down
39 changes: 35 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ jobs:
# directions.
- name: Licensing matrix gate tests
run: bash scripts/test-licensing-matrix-gate.sh
# The grader engine-smoke-test.yml's unity-abort-message job runs against
# a real Docker run (the Mirror#4128 abort message). Its two "must not
# contain" assertions can only ever pass *vacuously* on a run that died
# early, so this drives it both directions against hand-written logs -
# including the pre-#293 message it exists to reject - to pin that a
# degraded stub or a reintroduced "Original error:" section still fails.
# No Docker, no Unity, no network.
- name: Unity abort message grader tests
run: bash scripts/test-assert-unity-abort-message.sh
# scripts/install.sh is fetched and run directly by engine wrappers
# (unity-builder and future ones) - see its own header comment - so a
# syntax error here breaks every wrapper's CLI install step, not just
Expand All @@ -190,6 +199,18 @@ jobs:
# lands in that window - e.g. this repo's own version-bump commit,
# created right after cutting the release it's for - hit exactly
# this 404 in practice, failing Tests on main itself, not just a PR.
#
# GH_TOKEN because install.sh resolves "latest" through the GitHub
# API and only authenticates when handed one (see its own comment).
# Unauthenticated is 60 requests/hour *per IP*, and Actions runners
# share IPs across unrelated repos and orgs - so the macOS job below,
# running the same install.sh against the same endpoint seconds
# later, was 403ing on that limit while this job passed beside it.
# install.sh reads GITHUB_TOKEN or GH_TOKEN and treats an empty one
# as no token, so a fork PR (no secrets, read-only github.token)
# degrades to today's unauthenticated behaviour rather than failing.
env:
GH_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN || github.token }}
run: |
for attempt in 1 2 3 4 5; do
# install.sh writes progress to stderr and only the final binary
Expand All @@ -203,7 +224,7 @@ jobs:
echo "FAIL: scripts/install.sh still failing after 5 attempts"
exit 1
fi
echo "install.sh failed (attempt $attempt/5) - likely the latest release's binaries are still uploading - retrying in 30s..."
echo "install.sh failed (attempt $attempt/5) - see the error above; retrying in 30s..."
sleep 30
done
[ -x "$binary_path" ] || { echo "FAIL: $binary_path is not executable"; exit 1; }
Expand All @@ -221,6 +242,10 @@ jobs:
# above has already established that the latest release is fully
# uploaded.
- name: Smoke-test the root install.sh wrapper
# Same GH_TOKEN reason as the step above: the wrapper defaults to
# "latest" and resolves it through the same API call.
env:
GH_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN || github.token }}
run: |
export GAME_CI_INSTALL=/tmp/root-install-sh-smoke-test
sh ./install.sh
Expand Down Expand Up @@ -257,8 +282,10 @@ jobs:
*) echo "FAIL: expected macOS's system bash to be 3.x, got $bash_version - this job no longer covers the bash-3.2 compatibility class it exists for"; exit 1 ;;
esac
- name: Smoke-test scripts/install.sh against the latest release
# Same retry rationale as the ubuntu job: "latest" can resolve to a
# release whose binaries are still uploading.
# Same retry rationale, and the same GH_TOKEN reason, as the ubuntu
# job above - this is the job the token exists to stop 403ing.
env:
GH_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN || github.token }}
run: |
for attempt in 1 2 3 4 5; do
if binary_path="$(/bin/bash scripts/install.sh latest /tmp/install-sh-smoke-test)"; then
Expand All @@ -268,13 +295,17 @@ jobs:
echo "FAIL: scripts/install.sh still failing after 5 attempts"
exit 1
fi
echo "install.sh failed (attempt $attempt/5) - likely the latest release's binaries are still uploading - retrying in 30s..."
echo "install.sh failed (attempt $attempt/5) - see the error above; retrying in 30s..."
sleep 30
done
[ -x "$binary_path" ] || { echo "FAIL: $binary_path is not executable"; exit 1; }
[ -d "$(dirname "$binary_path")/dist" ] || { echo "FAIL: dist/ was not extracted next to $binary_path"; exit 1; }
"$binary_path" --help
- name: Smoke-test the root install.sh wrapper
# Same GH_TOKEN reason as the step above: the wrapper defaults to
# "latest" and resolves it through the same API call.
env:
GH_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN || github.token }}
run: |
export GAME_CI_INSTALL=/tmp/root-install-sh-smoke-test
/bin/sh ./install.sh
Expand Down
Loading
Loading