/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.