Dentolize · Team-Managed Sandbox Config Walkthrough
On this pageBusiness viewTechnical view

Environment-sourced settings

Business view

Every Dentolize PR gets its own disposable test environment (a "sandbox") that engineers can tune — turn WhatsApp on, add test credentials, disable cron jobs — by writing a small settings block into the PR description. That's fine for a PR, which always has a description to edit. It doesn't work for main, which redeploys straight from pushes and has no PR to attach settings to. So the shared demo/training sandbox the whole team uses — the one shown to prospects, used in training, and poked at for manual QA — has been stuck on defaults since it was introduced.

This PR gives the team a second, equally real place to store that configuration: a GitHub "Environment" named sandbox, which is really just a named bucket of key/value variables and secrets that repository admins manage from the GitHub UI (Settings → Environments). Anyone who can edit that Environment can now change what main's sandbox looks like, the same way a PR author already changes what their own PR's sandbox looks like — without needing an open PR at all.

The rule for scoping a setting to a branch is a naming prefix:

You set (variable or secret name)Applies to
SANDBOX_ALL_<KEY>every branch this workflow ever deploys via this mechanism
SANDBOX_<REF>_<KEY>only that one branch — e.g. SANDBOX_MAIN_WHATSAPP applies only to main

Branch-specific settings win over SANDBOX_ALL_* on the same key.

Technical view

Where it plugs in

.github/workflows/sandbox-ops.yml:44-48 adds environment: sandbox to the ops job. Declaring an environment: on a job is what makes that environment's variables and secrets available in the vars / secrets GitHub Actions contexts for steps in that job — without it, toJSON(vars) / toJSON(secrets) would only see repository- and organization-level values.

The new step, .github/workflows/sandbox-ops.yml:64-109 ("Compose sandbox settings from the sandbox environment"), runs only if: inputs.action == 'redeploy' — i.e. only wired into the redeploy action of the five sandbox-ops.yml supports (reseed, reset-data, redeploy, destroy, update-docs). reseed/reset-data/destroy don't redeploy the stack, so there's nothing for profile toggles or env overrides to affect.

The compose logic

The step's env: block captures three inputs (sandbox-ops.yml:66-69):

  • SANDBOX_VARStoJSON(vars), all variables visible to the job (repo + org + the sandbox environment)
  • SANDBOX_SECRETStoJSON(secrets), same but for secrets
  • REF_NAMEgithub.ref_name, the branch the workflow was dispatched against

A Python heredoc (sandbox-ops.yml:73-98) then:

  1. Parses both JSON blobs, defaulting to {} on any error (load(), line 75-77).
  2. Derives ref_key by uppercasing the branch name and replacing every run of non-alphanumeric characters with _ (line 80) — e.g. feature/fooFEATURE_FOO. This means a branch named main maps to prefix SANDBOX_MAIN_.
  3. Builds prefixes = ["SANDBOX_ALL_", f"SANDBOX_{ref_key}_"] (line 81) — order matters: the loop below iterates prefixes in this order, so branch-specific keys are written to merged after SANDBOX_ALL_ keys and overwrite them on collision.
  4. Iterates (vars_, secrets_) in that order (line 86) and, for every key matching either prefix, strips the prefix and writes into a flat merged dict (lines 86-90). Because secrets_ is processed second, a secret with the same post-prefix key as a variable overwrites it — "secrets win on a name clash," per the PR description.
  5. Splits merged into out (the settings object) and env (the override map) using a fixed reserved-key table (line 82-83):

``python TOGGLES = {"WHATSAPP": "whatsapp", "TWO_REGIONS": "two_regions", "CRON": "cron", "QUEUE": "queue"} ``

A key matching one of these becomes a boolean profile toggle via truthy() (line 84: matches "1", "true", "yes", "on", case-insensitively, after stripping whitespace). SEED becomes out["seed"] verbatim (no truthy coercion — it's a string like rich/basic, matching the PR-body block's seed: field). Everything else lands in the env dict unchanged and, if non-empty, is nested under out["env"] (line 96).

  1. Prints the resulting JSON to stdout, which the surrounding run: block redirects into $RUNNER_TEMP/sandbox-settings.json (line 73).

Why this is the same shape the PR-body path produces

Compare against .github/PULL_REQUEST_TEMPLATE.md:20-37, which documents the PR-body ## 🧪 Sandbox YAML block, and sandbox.yml:92-149 ("Parse sandbox settings from PR description"), which turns that YAML into JSON with a two_regions / whatsapp / cron / queue / seed / env shape. This PR's compose step deliberately outputs the identical shape — {"whatsapp": bool, "two_regions": bool, "cron": bool, "queue": bool, "seed": str, "env": {...}} — so that the CLI-facing contract ($SANDBOX_SETTINGS_FILE → the sandbox CLI's load_settings, invoked at sandbox-ops.yml:111-128) needed zero changes. Both code paths just produce different JSON files that flow into the same sandbox dentolize <ref> deploy invocation.

One deliberate omission: keep_on_merge (present in the PR-body schema) has no environment-variable equivalent here, because it only makes sense for a PR that can merge — main never "merges."

Logging

Lines 101-108 log the composed settings to a collapsed ::group::, but only **toggle values and env key names** — never env values (d.get("env") or {}).keys(), not .values()). The PR description explains this is belt-and-suspenders: secret values would be auto-masked by GitHub Actions regardless, but plain variable values might still be internal config the team doesn't want echoed into a shared job log.

Result handoff

The last line, echo "SANDBOX_SETTINGS_FILE=$sf" >> "$GITHUB_ENV" (line 109), publishes the file path into the job's environment for later steps — same mechanism, same variable name, as the PR-body path in sandbox.yml:149. The next step, ${{ inputs.action }} sandbox for ${{ github.ref_name }} (sandbox-ops.yml:111-128), picks up SANDBOX_SETTINGS_FILE implicitly (it's inherited via $GITHUB_ENV, not re-declared) and runs sandbox dentolize "$REF" deploy, which is where the settings file is actually consumed by the CLI's load_settings.