Skip to content

Runners and install runners

Chris Dowin edited this page Oct 6, 2026 · 4 revisions

Runners and install-runners

Each file once; after that it is the repo's. A differing destination is refused by name (--force overwrites it), --diff prints what would change and writes nothing, and the run ends by printing the .claude/settings.json entry that fires the engine-boot guard, after the pyproject.toml block that locks the kit. It runs uv run --frozen godot-devkit when uv.lock names the kit, or the legacy GODOT_DEVKIT_VERSION from the Makefile above the include, which wins with a warning when both are present.

Makefile.tiers                          the Godot tier roster on the seam Makefile.gates -includes:
                                        parse lint spot warnings unit integration integration-all
                                        integration-diff integration-list scenario smoke capture
                                        import-cache godot-check uid-scan hermetic-scan
                                        runners-self-test, with
                                        GDK_PRECOMMIT_TIERS / GDK_VERIFY_TIERS
tools/dev/gdk_runners.sh                the sandboxed headless-run library every runner sources
tools/dev/runners/parse.sh              every .gd compiles + the headless boot is clean
tools/dev/runners/compile_sweep.gd      stage 2 of parse.sh (+ its .uid sidecar)
tools/dev/runners/lint.sh               gdlint over every tracked source dir
tools/dev/runners/spot.sh               gdlint + a compile of only the .gd changed vs BASE
tools/dev/runners/warnings.sh           the analyzer warnings only the editor shows
tools/dev/runners/unit.sh               the GUT tier, no boot; a census that must reconcile
tools/dev/runners/integration.sh        the scenario fan-out: --all, --diff <ref>, --system <dir>, --list;
                                        warm (one boot per worker) under GDK_INTEGRATION_WARM=1
tools/dev/runners/scenario.sh           one scenario, cold, with the cache-recovery ladder; --suite <name>...
                                        is the warm worker: one boot, several scenarios
tools/dev/runners/capture.sh            a headed visual capture to PNG (local, needs a display); the window
                                        is placed off screen unless CAPTURE_VISIBLE=1 (a desktop may clamp it to the edge), and the last PNG of a
                                        name moves to previous/ for a before/after
tools/dev/runners/import_cache.sh       rebuild the .godot import cache, sandboxed, in a scratch copy:
                                        .godot/ comes back by rename, new .uid/.import sidecars
                                        are copied back, other rewrites are dropped and counted
tools/dev/runners/hermetic_run_scan.sh  a headless run's sandbox HOME self-destructs
tools/hooks/cc-godot-sandbox.sh         the Claude Code PreToolUse guard: no raw engine boot
.github/actions/godot-toolchain/action.yml
                                        the CI toolchain: engine, gdlint, shellcheck, import

CI. The workflow is yours; give it one step, after setup-uv, that fills the toolchain slot (the run prints it as a next: line):

      - uses: ./.github/actions/godot-toolchain
        with: { godot-patch: "<n>" }

The action reads the engine's MAJOR.MINOR from project.godot config/features and adds godot-patch; it installs gdlint (gdtoolkit-version, default 4.5.0) and shellcheck (shellcheck-version, default 0.11.0, from the release tarball), then imports the project and fails when .godot/global_script_class_cache.cfg is absent. install-runners no longer writes .github/workflows/uid-guard.yml. An existing copy stays, and the run names it as retired. It is safe to delete only when GDK_CHECKS names godot-check and [roster] checks keeps uid: then check uid runs in make check, and so in make verify. uid-scan is not a verify tier, so without godot-check in GDK_CHECKS the file is the only CI uid gate: keep it.

Preinstalled engine and import: false (3.3.0). A runner image may carry the engine at <godot-root>/<version>/godot (input godot-root, default /opt/godot); the action then uses it and downloads nothing. shellcheck skips its download when the pinned version is installed. A workflow that imports once per run and restores .godot/ in its other jobs passes import: false to them; the class-cache assert still runs.

Releases (3.4.0; tag last since 3.6.0). install-runners also writes .github/workflows/release.yml. It runs on workflow_dispatch or on a pushed v* tag. A dispatch takes tag (required, starts with v, need not exist) and sha (optional; default the commit of the dispatch ref). An auto-tag job, or any decide step, passes both. A pushed tag builds that tag at its commit. The tag is created last, so a failed gate or export leaves no tag. Jobs, in order: config reads [release] from godot-devkit.toml and resolves the sha, and every job checks out that sha; gate runs the release gate, one parallel job per gate entry (each up to gate_timeout minutes, 30 by default); export exports each preset and uploads its archive as a workflow artifact (release-<slug>, kept 1 day); release downloads the archives and fails, creating nothing, if the tag exists at another commit. Otherwise it creates or updates the GitHub Release at the sha (this creates the tag), its body the merging PR's ## Release notes section or generated notes, and uploads the assets. It never deletes Actions caches. [release] keys:

key default
godot_patch none; required (MAJOR.MINOR comes from project.godot)
title the repository name
gate verify if the Makefile defines it, else milestone, else "" (no gate); a string or a list. Each entry is one parallel job running make <entry>; an entry may hold several targets and make variables (a shard: "integration-all GDK_SHARD=1/3"). Any red job stops the release
gate_timeout 30; the timeout-minutes of each gate job, a positive integer
before_export []: shell commands run before each export, with $GODOT set
version_file, version_placeholder "" (no injection); set both or neither
presets every [preset.N] in export_presets.cfg with name and export_path

[[release.presets]] tables take preset, and optionally output, archive (zip or tar.gz) and name. A default archive is tar.gz when the preset name holds "linux", else zip.

The diff slice. integration.sh --diff <ref> (make integration-diff) boots the scenarios whose ## covers: header names a touched path, plus smoke. A touched file under GDK_SCENARIO_FIXTURE_DIR (default tests/support/) selects every scenario whose text names it, followed through other fixtures. A fixture is named by its res:// path in any quote or none, by its own uid://, or by a class_name it declares, so no call form can drop a scenario out of the slice. Each fixture prints how many scenarios it selected. A touched fixture that no scenario names boots the whole tier with the line fixture <path> is referenced by no scenario — booting the tier. A fixture root that is not a directory under the repo exits 2. With the root set elsewhere, a touch under tests/support/ (1.3.0's ground) still boots the tier. Only the runners and the scenario base/runner scripts are the tier's ground (GDK_SCENARIO_SUBSTRATE_RE; a value you set is kept). After the sweep, --diff reruns each failed scenario once, alone. One that passes alone counts green and prints FLAKE <name> — failed in the sweep, passed alone, and the summary reads N passed (K flaky). --no-rerun or GDK_INTEGRATION_RERUN=0 turns the rerun off; --all and named runs never rerun. Before a --diff/--all sweep boots anything, a stale import cache (.godot/uid_cache.bin missing, or older than a tracked or untracked-not-ignored *.uid, *.import or project.godot, compared to the nanosecond) is repaired once by import_cache.sh. If the repair fails, the sweep does not start (exit 1; the repair's own usage or config error passes through as exit 2).

Warm mode (opt-in). Every scenario is one cold engine boot, 13-15 s of CPU before its first assertion. With GDK_INTEGRATION_WARM=1, integration.sh --all|--diff|--system splits the roster (after --diff slicing) into GDK_JOBS slices, balanced by count, and runs each through ONE scenario.sh --suite a b c, so the engine boots once per worker. Unset (the default), every path is the cold one, byte for byte; --cold forces it for one run, and a value other than 0 or 1 exits 2. Turn it on only once your scenario runner implements the contract:

  1. Booted with -- <GDK_SCENARIO_SUITE_ARG> a,b,c (default --scenarios, comma-separated), print [SCENARIO] <name> START before each scenario: a line matching <GDK_SCENARIO_START_RE> <name> START, where the ERE defaults to \[SCENARIO\] like GDK_SCENARIO_RESULT_RE.
  2. Run it against a fresh World, with the reset your scenario_base owns (autoload state, World, player).
  3. Finish the scenario's teardown, THEN print your usual verdict line: GDK_SCENARIO_RESULT_RE, then PASS or FAIL as a word. The verdict closes the scenario; nothing it causes may follow.
  4. Exit 0 after the last one. A FAIL is carried by its verdict line, never by the exit code.

The single-scenario --scenario <name> path is unchanged. The worker splits the stream at the START markers. A scenario's slice runs from its START to the next one, with the boot preamble in front, and is published to .scenario-reports/<name>.log. An engine error in the slice upgrades that scenario's PASS to FAIL, exactly as a cold run would, and each scenario gets one console line, as today. A worker is killed when no new START or verdict arrives within GDK_SCENARIO_HARD_TIMEOUT seconds (a per-scenario bound). A hang hands back the scenario in progress and every one that never started, unrun: WARM-ABORT after <last finished> — N scenario(s) handed back, and scenario.sh --suite exits 4. Two findings belong to no scenario, so they hand back EVERY member, even ones with a verdict, and the WARM-ABORT line names the reason after a colon. The first is an engine exit that is non-zero and not the hang kill (: the engine exited 139), even after the last verdict. The second is an engine error after a verdict and before the next START or the exit (: engine errors after the last verdict), which is where exit-time leak warnings land. A passing slice that carries the cold-import-cache warning is handed back too, so the cold path's recovery ladder gets it. Every handed-back scenario then runs cold.

A scenario whose header carries ## Isolated because: <reason> never runs warm. An empty reason exits 2 and names the file; so does a near-miss spelling (# Isolated because:, ## isolated because:), naming its line, rather than running the scenario warm in silence. With --diff, a warm failure is rerun cold and alone. One that passes prints WARM-ONLY <name> — failed warm, passed cold: it leans on process state; mark it "## Isolated because:" or fix its reset, and counts green. --no-rerun leaves it red. The summary reads N passed (K flaky, W warm-only), F failed (of T); warm A, cold B, handed back C. A, B and C must sum to the roster, and every scenario must have a result. Otherwise the run FAILS and names what is missing. [INTEGRATION] WALL: warm workers Xs, cold remainder Ys splits the wall clock, and BOOTS counts one boot per worker. Compare before and after in the run output.

Every runner carries --help and a --self-test corpus; make runners-self-test replays them all. The guard's corpus (bash tools/hooks/cc-godot-sandbox.sh --self-test) is replayed by this kit's own tests, so do not add it to GDK_CHECKS. Every gate prints ONE verdict line naming its full transcript under .gate-reports/; VERBOSE=1 streams the whole thing.

The spot check. make spot SYS=<slice> is a builder's whole proof. spot.sh runs gdlint and compile_sweep.gd over the .gd files that differ from the merge base of HEAD and BASE (GDK_SPOT_BASE, default main): committed, staged and unstaged edits, and untracked files. The sweep takes the list as user arguments (-- res://a.gd …) and walks nothing else. When no .gd changed, the verdict is [SPOT] PASS — census 0: no .gd changed vs merge-base <sha> with <base>. Then unit.sh $(SYS) runs, and the exit is the worse of the two. GDK_PRECOMMIT_TIERS is spot, so make precommit runs check + spot and its first line says it is retired.

Receipts. A tier that passes files a receipt keyed on its inputs, and a run over the same inputs prints the recorded verdict with ; reused — receipt <id> and boots nothing. parse, lint, spot, unit, warnings (keyed on GDK_WARNING_CATEGORIES too), integration and a direct scenario file them. The integration key holds the arguments, the roster env, the tree of the --diff ref, and the roster files with every ## covers: path, and it is asked before the engine lease. A scenario run inside a sweep, under --suite or with -v files none. A path a runner names in GDK_RECEIPT_PATHS joins the key even inside a GDK_RECEIPT_EXCLUDE directory. GDK_RECEIPTS=0 runs every tier.

Several lanes on one machine. unit.sh bounds its run at 180 s times ceil(1-minute load / cpus), clamped to 1-3, and opens with the bound it chose and why ([UNIT] timeout 360s (2x, load 4.04 on 4 cpu(s))); an explicit GDK_UNIT_TIMEOUT is used as given, and a HARD_TIMEOUT names the value to rerun with. Sourcing gdk_runners.sh exports GIT_OPTIONAL_LOCKS=0, so a gate killed mid-git status leaves no .git/index.lock. A pid counts as dead only on positive evidence (kill -0 says No such process, or a visible process table lacks it), so the HOME reaper never deletes a peer's live run. Where a sandbox hides pid 1 from ps, the library's self-test prints SKIP — … for its two foreign-pid cases instead of failing them.

What a tier costs. unit prints its GUT test count, and the scenario tiers print a census of boots ([INTEGRATION] BOOTS: <n> …). A scenario file is one cold engine boot whatever its length, so the scenario tier's cost is its file count: merging two scenarios saves a boot, trimming lines saves nothing. check test-shape's line cap is a readability gate, not a cost one.

See also: CI recipes and shards, Gate framework.

Clone this wiki locally