Dentolize · Sandbox Command & Registry Walkthrough
On this pageBusiness viewTechnical view

/sandbox — the comment command

Business view

Before this PR, getting a preview environment for a pull request meant one of two things: pushing a commit to a PR that had the right description block (from #428), or going to the GitHub Actions tab, finding Sandbox Ops, clicking "Run workflow", and picking the correct branch from a dropdown. Neither is discoverable to someone reviewing a PR who just wants to click around the actual app.

/sandbox turns that into a one-line comment, typed right where the review is already happening:

  • /sandbox — create the sandbox if it doesn't exist yet, or redeploy it to

the latest commit if it does.

  • /sandbox destroy — tear it down (e.g. once review data got messy).
  • /sandbox reseed — fix broken logins without losing test data.
  • /sandbox reset-data — wipe and start over with a clean seed.
  • /sandbox docs — regenerate this documentation site for the branch.

The command only works for people with write access to the repo — anyone else's /sandbox comment gets a 👎 reaction and nothing else happens. There's no separate approval step or audit log beyond that reaction and GitHub's own comment history.

Technical view

File: .github/workflows/sandbox-command.yml (new, 115 lines)

Trigger and gating

The workflow listens for issue_comment: created — GitHub fires this event for comments on both issues and PRs, so the job condition filters to PR comments starting with /sandbox:

if: >-
  github.event.issue.pull_request != null &&
  startsWith(github.event.comment.body, '/sandbox')

(sandbox-command.yml:33-36)

Because issue_comment handlers always run the workflow file as committed on the repo's default branch (not the PR's branch), this single copy on main covers every open PR immediately once merged — PR branches don't need to carry their own copy of the file. This is called out explicitly in the file header (sandbox-command.yml:13-14).

Concurrency is scoped per PR issue number, and does not cancel an in-flight dispatch (sandbox-command.yml:21-24) — a second /sandbox comment on the same PR queues behind the first rather than racing it.

Permission gate

const assoc = comment.author_association;
if (!["OWNER", "MEMBER", "COLLABORATOR"].includes(assoc)) {
  await react("-1");
  core.info(`ignored /sandbox from ${assoc}`);
  return;
}

(sandbox-command.yml:56-61)

author_association is computed by GitHub itself from repo membership at comment time, so this can't be spoofed by comment body content. Non-members (e.g. CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR, NONE) are silently declined with a 👎 reaction — no reply comment, so there's no feedback loop that could be used to enumerate valid commands as an outsider.

Sub-command parsing

const arg = (comment.body.trim().split(/\s+/)[1] || "").toLowerCase();
const map = {
  "": "redeploy", "create": "redeploy", "deploy": "redeploy",
  "redeploy": "redeploy", "up": "redeploy",
  "destroy": "destroy", "down": "destroy",
  "reseed": "reseed", "reset-data": "reset-data", "reset": "reset-data",
  "docs": "update-docs", "update-docs": "update-docs",
};

(sandbox-command.yml:65-73)

This is a flat lookup table, not a parser — /sandbox and its accepted aliases are the only second words recognized; anything else (including typos) falls into the "unknown command" branch, which reacts 😕 and replies with a usage hint (sandbox-command.yml:74-82). The parsing only ever looks at split(/\s+/)[1], so trailing text after the first argument (e.g. /sandbox destroy please) is ignored rather than rejected.

Dispatch mechanism

The workflow resolves the PR's head branch via pulls.get (sandbox-command.yml:85-86), then POSTs directly to the REST workflow_dispatch endpoint for sandbox-ops.yml:

const token = fs.readFileSync('/opt/homelab/sandbox/qa-agent/gh-token', 'utf8').trim();
const res = await fetch(
  `https://api.github.com/repos/${owner}/${repo}/actions/workflows/sandbox-ops.yml/dispatches`,
  { method: "POST", headers: { authorization: `Bearer ${token}`, ... },
    body: JSON.stringify({ ref: branch, inputs: { action } }) },
);

(sandbox-command.yml:88-104)

Two things worth being precise about:

  • Why not github.token: the comment in the file explains that the

default GITHUB_TOKEN cannot start a workflow_dispatch run (sandbox-command.yml:88-90) — this is a real GitHub Actions restriction on the default token, not a workaround of convenience. A host-persisted personal access token at /opt/homelab/sandbox/qa-agent/gh-token on the self-hosted runner is used instead. That file is host state, not tracked in this repo, so its provisioning/rotation can't be verified from /work/repo.

  • This workflow does no deploy work itself. It only translates a comment

into a workflow_dispatch call — all of the actual redeploy/destroy/seed logic still lives in sandbox-ops.yml, unchanged by this PR. If that dispatch call fails (!res.ok), the workflow reacts 👎, replies with the HTTP status and truncated response body, and calls core.setFailed (sandbox-command.yml:105-111).

What happens next

Sandbox Ops runs on [self-hosted, homelab, dentolize] and, for every action except destroy and update-docs, posts/updates a single sandbox-info comment on the PR keyed by an HTML marker (<!-- sandbox-preview-comment -->, sandbox-ops.yml:203, :336-356). That comment carries the web URL and seeded credentials — it is not written by sandbox-command.yml, so a successful /sandbox only guarantees the dispatch was accepted, not that the deploy itself succeeded; that result shows up a little later as the sandbox-info comment (or its absence, if Sandbox Ops failed).

/sandbox docs is the odd one out: update-docs runs the docs agent in the foreground with a 90-minute timeout (sandbox-ops.yml:48-52) and does not touch the sandbox-info comment at all (sandbox-ops.yml:189) — its result is a rebuilt docs site (this one), not a PR comment.