The precedence chain
Business view
Think of it like a form with pre-filled defaults. Your company sets a baseline (a company-wide default), your team can override that baseline for its own branch, and you can still cross out any single field and write your own answer — without having to retype the rest of the form. Nobody's override gets silently lost, and nobody has to repeat settings someone else already set.
Concretely, for Dentolize sandboxes, the "form fields" are things like "should this sandbox run the WhatsApp integration?" or "should this sandbox run in the two-region (EU-1/ME-1) topology?" Before this PR, only the PR author could answer those questions, and only by editing their own PR description. Now there's a team-wide default that fills in every field a PR doesn't explicitly answer.
Technical view
Source: .github/workflows/sandbox.yml:98-188 (step id settings, job deploy).
The five layers
built-in compose defaults (baseline the CLI itself falls back to)
→ SANDBOX_ALL_* variables (org/repo "vars", `sandbox` environment)
→ SANDBOX_ALL_* secrets (org/repo "secrets", `sandbox` environment)
→ SANDBOX_<BRANCH>_* variables
→ SANDBOX_<BRANCH>_* secrets
→ PR-body `## 🧪 Sandbox` block (always wins)
Each layer is a flat dict; a later layer overwrites a key an earlier layer set, key by key — a PR that only sets one key doesn't have to repeat anything else (sandbox.yml:156-157, sandbox.yml:163-176).
Branch key sanitization
ref_key = re.sub(r"[^A-Z0-9]+", "_", os.environ.get("HEAD_REF", "").upper()).strip("_")
.github/workflows/sandbox.yml:146
github.head_ref is the PR's source branch name (e.g. ci/sandbox-layered-settings). It's uppercased and every run of non [A-Z0-9] characters (/, -, ., …) collapses to a single _, with leading/trailing underscores stripped. ci/sandbox-layered-settings becomes CI_SANDBOX_LAYERED_SETTINGS, so a variable/secret named SANDBOX_CI_SANDBOX_LAYERED_SETTINGS_CRON targets that exact branch. Two differently-punctuated branch names that sanitize to the same key (ci/foo and ci-foo both → CI_FOO) would collide — an edge worth testing, see For Quality.
Layer construction and merge order
seq = [(vars_, "SANDBOX_ALL_"), (secrets_, "SANDBOX_ALL_")]
if ref_key:
seq += [(vars_, f"SANDBOX_{ref_key}_"), (secrets_, f"SANDBOX_{ref_key}_")]
for src, prefix in seq:
flat.update(layer(src, prefix))
.github/workflows/sandbox.yml:153-157
layer() strips the prefix and keeps only matching keys (sandbox.yml:150-151). The list order is the precedence order: variables before secrets within a scope, global (ALL) before branch-scoped — so a branch-scoped variable still beats a global secret, matching "branch beats global; within a scope, secret beats variable" from the PR description.
Toggles, seed, and everything else
TOGGLES = {"WHATSAPP": "whatsapp", "TWO_REGIONS": "two_regions",
"CRON": "cron", "QUEUE": "queue"}
truthy = lambda v: str(v).strip().lower() in ("1", "true", "yes", "on")
...
for k, v in flat.items():
if k in TOGGLES: out[TOGGLES[k]] = truthy(v)
elif k == "SEED": out["seed"] = v
else: env[k] = v
.github/workflows/sandbox.yml:147-162
After stripping the SANDBOX_ALL_/SANDBOX_<branch>_ prefix, four names are reserved as boolean profile toggles and get parsed through truthy() (so "1", "true", "yes", "on" — case-insensitively — all mean on, anything else means off); SEED is reserved as the seed-mode string (passed through as-is, e.g. rich or basic); every other key becomes an env override passed straight to the sandbox's compose stack.
The PR block always wins, key by key
for k, v in (pr.get("env") or {}).items():
...
env[k] = v
for tk in ("two_regions", "whatsapp", "cron", "queue", "seed"):
if tk in pr: out[tk] = pr[tk]
if env: out["env"] = env
.github/workflows/sandbox.yml:163-176
The PR's own top-level YAML keys (whatsapp: true, seed: rich, etc.) and its env: map are applied last, one key at a time, over whatever the GitHub-side layers already produced. An empty or missing PR block (pr = {}) changes nothing — the GitHub-side result passes through untouched, which is exactly what the docs sandbox for this PR itself demonstrates (see Walkthrough).
Output contract, unchanged
The final out dict — {env?, whatsapp?, two_regions?, cron?, queue?, seed?} — is serialized with json.dumps(out) straight to the settings file (sandbox.yml:177), the same shape the previous version wrote (it also just captured whatever top-level keys the PR's own YAML block contained). That's what backs the PR description's "no CLI change" claim: load_settings on the runner reads the same keys regardless of which layer produced them.