Skip to content

fix: make debug console log ingestion thread-safe via pending queue #17

fix: make debug console log ingestion thread-safe via pending queue

fix: make debug console log ingestion thread-safe via pending queue #17

name: In-World Tests
# Dispatcher for the InWorld NUnit suite. The mechanics — Explorer install,
# AltTester, `mf explorer test`, Allure upload, PR comment — live in
# decentraland/explorer-automation's `run-inworld-suite.yml`. This file only
# decides when to run and against which build, and shares its build resolution
# with visual-regression.yml via .github/actions/resolve-explorer-build.
#
# Triggers:
# - Every PR into main from a `release/**` or `hotfix/**` branch. `pull_request`
# and not `workflow_run` on the build, because a workflow_run reports against
# the default branch's SHA: it never joins the PR's checks and so can never
# gate a merge. Here the check is on the PR from the moment it opens, and the
# first job waits out the ~40-minute Unity Cloud Build the same event started.
# - Manually, from the Actions tab, against any ref.
#
# To make a red suite block the merge, add `InWorld suite result` to main's
# required status checks. That job is the gate: it is the only name worth
# requiring, it stays stable when the reusable workflow's own job names change,
# and it passes for PRs into main that are not release or hotfix.
#
# Release PRs are opened by create-release-branch.yml with GITHUB_TOKEN, and
# events that token raises start no workflow runs — so that workflow dispatches
# this one itself. A dispatch attaches its checks to the dispatched ref's tip,
# which is the PR head, so the gate still lands on the PR.
#
# Required vars:
# - vars.EXPLORER_TEAM_S3_BUCKET_PUBLIC_URL Artifact bucket, and where the
# Allure report is published
#
# Inherited by the reusable workflow via `secrets: inherit`:
# - secrets.ALTTESTER_LICENSE
# - secrets.REPOS_READ_ONLY_TOKEN
# - secrets.EXPLORER_TEAM_S3_BUCKET
# - secrets.EXPLORER_TEAM_AWS_DEFAULT_REGION
# - secrets.EXPLORER_TEAM_AWS_ACCESS_KEY_ID
# - secrets.EXPLORER_TEAM_AWS_SECRET_ACCESS_KEY
on:
pull_request:
branches: [main]
types: [opened, reopened, synchronize, ready_for_review]
workflow_dispatch:
inputs:
filter:
description: 'NUnit filter. Empty runs the whole Category=InWorld suite, split across Windows runners; anything narrower runs on one.'
required: false
default: ''
type: string
build_url:
description: 'Artifact URL to test. Empty resolves the selected ref''s own Unity Cloud Build.'
required: false
default: ''
type: string
tests_ref:
description: 'explorer-automation ref for the tests. Empty matches this ref''s name there, else main.'
required: false
default: ''
type: string
record_perf:
description: 'Record fixture-level perf samples. Off by default — paravirt numbers are unreliable.'
required: false
default: false
type: boolean
# A newer commit supersedes the run in flight: the question is whether the branch
# is good now, and the newer commit contains the older one.
#
# Keyed on the branch, not the PR number, because both triggers have to land in
# one group: create-release-branch.yml dispatches this workflow against the same
# release branch a `pull_request` run also covers, and a PR-number key would put
# the two in groups that can never cancel each other — two gates on one commit,
# four shards on the self-hosted GPU pool, and whichever finishes last owning the
# required check. `github.head_ref` is the source branch on `pull_request` (where
# `ref_name` is the merge ref) and empty everywhere else, so the fallback is what
# a dispatch and a manual run against any ref key on.
#
# Keyed on the head repo as well, because a branch name is not ours to reserve:
# a fork PR into main may name its branch `release/2026-08-10` too, and
# `cancel-in-progress` is applied when the run is created — before any job's
# `if:` is evaluated, so the fork exclusion in `resolve`'s own `if:` (:113)
# only skips the jobs of a run that has already cancelled the real gate.
# Without this segment an outsider could red a release's required check at
# will, by guessing a name that release cuts make predictable.
# `head.repo.full_name` is the head repo on `pull_request` — ours on a same-repo
# PR, `attacker/whatever` on a fork — and absent on `workflow_dispatch`, whose
# payload carries only `inputs`, `ref` and `workflow`; the fallback therefore
# resolves to the same string a same-repo PR produces, which is what keeps the
# two triggers in one group. A fork cannot collide with us: everything before
# the first `/` is the head repo's owner, and only we can be `decentraland`.
concurrency:
group: in-world-tests-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
resolve:
name: Wait for the build
# Skipped rather than filtered at the trigger, so the gate job below still
# reports on every PR into main and a required check never hangs unreported.
#
# Drafts are excluded to match build-unitycloud.yml, which does not build
# them without a `force-build` label — waiting for a build that will never
# start just turns into a timeout. `ready_for_review` above picks the PR up
# the moment it leaves draft.
#
# Fork PRs are excluded because they get no secrets, and the suite needs the
# AltTester licence. Release and hotfix branches never come from a fork.
if: >-
github.event_name == 'workflow_dispatch' ||
((startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) &&
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
# Covers the wait below plus the artifact check. Builds measure ~40m.
timeout-minutes: 100
# `actions: read` for the build-unitycloud lookup, `contents: read` for the
# sparse checkout, `pull-requests: read` for the `gh pr list` below. Read
# access to public data survives whatever this block says while the repo is
# public, but the grant should not hinge on that. Nothing here writes.
permissions:
actions: read
contents: read
pull-requests: read
env:
HEAD_REF: ${{ github.head_ref || github.ref_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
outputs:
build_url: ${{ steps.resolve.outputs.build-url }}
windows_build_url: ${{ steps.resolve.outputs.windows-build-url }}
commit_sha: ${{ steps.resolve.outputs.short-sha }}
tests_ref: ${{ inputs.tests_ref || steps.resolve.outputs.tests-ref }}
branch_label: ${{ github.head_ref || github.ref_name }}
pr_number: ${{ github.event.pull_request.number || steps.pr.outputs.number }}
steps:
# `uses: ./…` resolves against $GITHUB_WORKSPACE, so the action has to be
# on disk. Sparse, because the full tree is ~21k files and none of the
# rest is read here.
- name: Fetch the resolver action
uses: actions/checkout@v6
with:
sparse-checkout: .github/actions/resolve-explorer-build
- name: Resolve build URL and tests ref
id: resolve
uses: ./.github/actions/resolve-explorer-build
with:
head-ref: ${{ env.HEAD_REF }}
head-sha: ${{ env.HEAD_SHA }}
# A PR opens before its build exists, so wait one out. Manual runs
# pick a ref whose build is already there — or say which to use.
wait-minutes: ${{ github.event_name == 'pull_request' && 90 || 0 }}
# Both suites run, so both zips have to exist before either starts.
build-legs: macos,windows64
build-url-override: ${{ inputs.build_url }}
public-url-prefix: ${{ vars.EXPLORER_TEAM_S3_BUCKET_PUBLIC_URL }}
github-token: ${{ secrets.GITHUB_TOKEN }}
automation-token: ${{ secrets.REPOS_READ_ONLY_TOKEN }}
# A dispatched run carries no PR, and the release gate is started that way
# — create-release-branch.yml dispatches it, because the PR it opens with
# GITHUB_TOKEN raises no events. Without this the result reaches only the
# job summary, on the one path where the PR is the whole point.
# `--head` matches on branch name alone, hence the fork exclusion.
- name: Find the branch's open PR
id: pr
if: github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
NUMBER=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$HEAD_REF" --state open \
--json number,isCrossRepository \
--jq '[.[] | select(.isCrossRepository == false)] | .[0].number // ""')
if [ -n "$NUMBER" ]; then
echo "::notice::Results will be commented on PR #${NUMBER}."
fi
echo "number=${NUMBER}" >> "$GITHUB_OUTPUT"
run-suite:
name: InWorld suite
needs: resolve
# @main pins the merged version of the reusable, so PRs to
# explorer-automation that touch it cannot change release validation here.
uses: decentraland/explorer-automation/.github/workflows/run-inworld-suite.yml@main
# Must be a superset of run-inworld-suite.yml's own `permissions:`, or the
# call dies at startup. The write is the PR comment it posts; this is the
# only job that needs one, hence per job rather than at the top of the file.
permissions:
contents: read
pull-requests: write
with:
build_url: ${{ needs.resolve.outputs.build_url }}
filter: ${{ inputs.filter || '' }}
# From the event on a PR run, looked up on a dispatched one.
pr_number: ${{ needs.resolve.outputs.pr_number }}
tests_ref: ${{ needs.resolve.outputs.tests_ref }}
commit_sha: ${{ needs.resolve.outputs.commit_sha }}
branch_label: ${{ needs.resolve.outputs.branch_label }}
# `== true` rather than the raw input: a pull_request event supplies no
# inputs at all, and the reusable's parameter is typed boolean.
record_perf: ${{ inputs.record_perf == true }}
# Both platforms gate a release. `windows` defaults to true upstream, but
# say it here anyway: a default that changes underneath a caller pinned to
# @main is how this repo silently acquired a Windows leg it could not run
# (explorer-automation#55, fixed by #73).
windows: true
# `build_url` is the macOS zip; the Windows leg needs its own or it
# resolves the newest dev build and reports on the wrong commit.
windows_build_url: ${{ needs.resolve.outputs.windows_build_url }}
# What the Windows leg splits across runners. The gate runs everything, so
# say ALL — the same thing inworld-main.yml says for the same reason.
#
# The planner would infer it: an empty scope plus the whole-category filter
# reads as ALL. But that inference exists to stop callers who resolved
# nothing from running unsplit, and a gate should not lean on a fallback
# for its runtime. Empty here is reserved for the other case — a manual run
# that passed a `filter` at all. This repo cannot map an arbitrary NUnit
# filter to a scope, so the planner infers the split from the filter
# instead; a narrow one lands on one runner as `1/1`.
scope: ${{ (inputs.filter || '') == '' && 'ALL' || '' }}
secrets: inherit
result:
name: InWorld suite result
needs: [resolve, run-suite]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
# Reads its `needs` results and nothing else — no API call, no checkout.
permissions: {}
steps:
# The one check worth requiring on main. Everything above is either
# plumbing or a job name owned by another repository.
- name: Gate on the suite
env:
RESOLVE: ${{ needs.resolve.result }}
SUITE: ${{ needs.run-suite.result }}
run: |
set -euo pipefail
if [ "$RESOLVE" = "skipped" ]; then
echo "::notice::Not a release or hotfix PR — the InWorld suite does not gate this one."
exit 0
fi
if [ "$RESOLVE" = "success" ] && [ "$SUITE" = "success" ]; then
echo "::notice::InWorld suite passed."
exit 0
fi
# Cancelled counts as failure on purpose: a merge gate must not go
# green because the run was interrupted. The superseding run reports
# its own result.
echo "::error::InWorld gate failed — build: ${RESOLVE}, suite: ${SUITE}. See the Allure report linked in the suite's job summary."
exit 1