In Depth: The Sandbox Docs Pipeline
Business view
Every pull request in this repository automatically gets two things:
- A sandbox — a private, fully-seeded, running copy of Dentolize at
https://<branch>.sandbox.anastawfik.com.
- A docs site — an AI-written walkthrough of that change (a site exactly
like the one you're reading) at https://<slug>.docs.anastawfik.com.
The docs site is produced by a docs agent — headless Claude Code — that reads the PR, drives the sandbox with a browser to capture real screenshots, and writes an audience-first walkthrough. There are two ways that agent runs:
- Automatically, once, when the PR is opened. This happens quietly in the
background so the build system stays responsive.
- On demand, whenever someone wants a fresh version. This is the
update-docs action — and it's the one this PR changes.
PR #354 only touches the on-demand path. It makes that refresh run in the open — you watch the agent work and know exactly when the site is live — instead of firing it off invisibly. The automatic path is deliberately left as fire-and-forget, because no human is sitting there watching a PR-open job.
Think of it as the difference between:
- Old: handing a task to someone and immediately marking it "done" on your
board because you handed it off.
- New: keeping the task "in progress" until it's genuinely finished, and being
able to watch it happen.
Technical view
The three workflows
The pipeline is three GitHub Actions workflows, all pinned to the homelab self-hosted runner (runs-on: [self-hosted, homelab, dentolize]):
| Workflow | Trigger | Role |
|---|---|---|
sandbox.yml | pull_request (opened/sync/reopened/closed) | Deploy/destroy the sandbox; auto-generate docs on open (detached) |
sandbox-ops.yml | workflow_dispatch | Manual ops incl. update-docs (the file this PR changes) |
sandbox-qa.yml | workflow_dispatch | AI QA agent (review/plan/test/fix), timeout-minutes: 300 |
The docs CLI: two verbs
Both docs paths shell out to the same on-runner CLI, /opt/homelab/sandbox/bin/sandbox-docs (which is not in this repo — its internals aren't verifiable from source):
generate— used by auto-generation on PR open:
``yaml # .github/workflows/sandbox.yml:66 /opt/homelab/sandbox/bin/sandbox-docs dentolize "${{ github.head_ref }}" generate --detach || true ` Runs **detached**; the || true means even a launch failure won't fail the PR's deploy job. Guarded by if: github.event.action == 'opened' (.github/workflows/sandbox.yml:59`), so it fires only on first open, not on every push.
refresh— used by the manualupdate-docsaction:
``yaml # .github/workflows/sandbox-ops.yml:65 (AFTER this PR) update-docs) /opt/homelab/sandbox/bin/sandbox-docs dentolize "${{ github.ref_name }}" refresh ;; ` Now runs in the **foreground** (the --detach flag was removed). Per the code comment (:45–46), refresh` may also deploy a missing sandbox first, which is why the timeout was raised to 90 minutes.
Where the finished site is announced
The docs URL is surfaced once, in the PR-open comment builder:
// .github/workflows/sandbox.yml:95
`**Docs:** https://${slug}.docs.anastawfik.com _(AI walkthrough — generates after PR open, refresh via Sandbox Ops → update-docs)_`,
Note what this implies: the only PR-thread mention of docs comes from sandbox.yml at open time. The update-docs action itself posts no comment (see below), so a refresh updates the site silently.
Why update-docs is comment-silent
In sandbox-ops.yml, two guards keep update-docs from touching the PR comment that the other ops (reseed, reset-data, redeploy, destroy) maintain:
# .github/workflows/sandbox-ops.yml:67 — info capture skipped for update-docs
if [ "${{ inputs.action }}" != "destroy" ] && [ "${{ inputs.action }}" != "update-docs" ]; then
/opt/homelab/sandbox/bin/sandbox dentolize "${{ github.ref_name }}" info >> "$GITHUB_OUTPUT"
fi
# .github/workflows/sandbox-ops.yml:72 — comment step skipped for update-docs
- name: Refresh sandbox comment on the branch PR
if: inputs.action != 'update-docs'
So the docs refresh has exactly two observable outputs: the job log and the updated site.
The end-to-end flow
┌───────────────────────── sandbox.yml (pull_request) ─────────────────────────┐
PR opened ─────────────►│ deploy sandbox ──► comment URLs/creds ──► generate --detach (docs, PR-open) │
new commit ────────────►│ deploy sandbox (cancel-in-progress: true) │
PR closed ─────────────►│ destroy sandbox │
└───────────────────────────────────────────────────────────────────────────── ┘
┌───────────────────── sandbox-ops.yml (workflow_dispatch) ────────────────────┐
pick branch + action ──►│ reseed / reset-data / redeploy / destroy ──► refresh PR comment │
│ update-docs ──► sandbox-docs … refresh (FOREGROUND) ──► site published │ ◄── THIS PR
└───────────────────────────────────────────────────────────────────────────── ┘
Cross-reference
The mechanics of why foreground vs. detached matters — runner slots, concurrency, timeouts — are covered in Foreground Execution & Runner Slots.