Dentolize · Layered Sandbox Settings Walkthrough
On this pageBusiness viewTechnical view

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.