Repository navigation
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:
- Booted with
-- <GDK_SCENARIO_SUITE_ARG> a,b,c(default--scenarios, comma-separated), print[SCENARIO] <name> STARTbefore each scenario: a line matching<GDK_SCENARIO_START_RE> <name> START, where the ERE defaults to\[SCENARIO\]likeGDK_SCENARIO_RESULT_RE. - Run it against a fresh World, with the reset your
scenario_baseowns (autoload state, World, player). - Finish the scenario's teardown, THEN print your usual verdict line:
GDK_SCENARIO_RESULT_RE, thenPASSorFAILas a word. The verdict closes the scenario; nothing it causes may follow. - 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.
Getting started
Reference
Guides
Upgrading
Maintaining