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

Open sandboxes — the pinned registry issue

Business view

With sandboxes now easier to create (via /sandbox) and already created automatically per PR (#428), it got harder to answer a simple question: what's actually running right now? Sandboxes accumulate — new PRs, old PRs left open, long-lived branches — and previously the only way to check was the Actions tab or asking in chat.

This PR keeps a single GitHub issue, 🗂 Open sandboxes, pinned to the top of the repo's issue list, always showing the current picture: which sandboxes are running (🟢) vs. parked (⏸️), which PR each belongs to, the web URL, the deployed commit, and how long ago it was deployed. It refreshes itself — after every relevant workflow run, every 30 minutes as a safety net, and on demand — so nobody has to remember to update it.

It's a companion to the existing 🧹 Sandbox teardown tracker (sandbox-gc.yml, unchanged by this PR), which separately flags sandboxes whose PR already closed. The registry answers "what's alive"; the tracker answers "what should probably be torn down."

Technical view

File: .github/workflows/sandbox-registry.yml (new, 116 lines)

Trigger

on:
  workflow_run:
    workflows: ["Sandbox", "Sandbox Ops", "Sandbox — main (demo/training)"]
    types: [completed]
  schedule:
    - cron: '*/30 * * * *'
  workflow_dispatch: {}

(sandbox-registry.yml:11-17)

Three of the four sandbox-producing workflows are named as workflow_run triggers — Sandbox (auto-deploy from #428), Sandbox Ops (this PR's manual + comment-driven executor), and Sandbox — main (demo/training) (sandbox-main.yml, the persistent main-branch demo environment). The 30-minute cron exists specifically to catch state changes that have no workflow run to hook into — the file header calls out "parks/reaps by the janitor" (sandbox-registry.yml:5-8), i.e. some out-of-band host process that can park or reclaim idle sandboxes without any GitHub Actions run happening. concurrency.cancel-in-progress: true (sandbox-registry.yml:19-21) means a newer refresh always wins over a stale one still running — unlike the command workflow, staleness here is fine to discard since the next refresh will reconcile again shortly.

Data source: the sandbox registry CLI

- name: Snapshot live sandboxes
  run: |
    /opt/homelab/sandbox/bin/sandbox registry > "$RUNNER_TEMP/registry.json"

(sandbox-registry.yml:32-36)

The workflow's only input is whatever JSON this host-side CLI emits. The PR description states the CLI gained a registry action that reads the state files plus docker ps for running/parked status, and that it was "live-tested against the current 23 sandboxes" — but the CLI itself lives at /opt/homelab/sandbox on the self-hosted runner, outside this repository.

What we cannot verify from this repo

/work/repo contains no sandbox CLI source, so the exact shape of each row (branch, status, web, commit, deployed, per the fields the github-script step consumes at sandbox-registry.yml:61-66) is inferred from how the workflow uses the JSON, not confirmed against the CLI's implementation. If the host-side registry action's output shape ever changes, this workflow would need a matching update that wouldn't show up as a diff in this repo.

Building the issue body

The github-script step (sandbox-registry.yml:38-116) does three things:

  1. Cross-reference open PRs by branchgithub.paginate(github.rest.pulls.list, { state: 'open' })

builds a branch → {number, url} map (sandbox-registry.yml:47-50), so each registry row can link to its PR. A row whose branch is main shows _demo_ instead of a PR link; anything else with no matching open PR shows (sandbox-registry.yml:63) — this is how a stale or orphaned sandbox becomes visible without the registry workflow needing to know anything about the GC tracker.

  1. Format each rowago() buckets deploy time into "just now" / `Nh

ago / Nd ago (sandbox-registry.yml:52-58); dot() maps CLI status strings to 🟢 running or ⏸️ parked, defaulting to parked for any value that isn't literally "running" (sandbox-registry.yml:59`).

  1. Upsert by marker, then pin:
const marker = '<!-- sandbox-registry -->';
const existing = open.find(i => !i.pull_request && i.body && i.body.includes(marker));
if (existing) { await github.rest.issues.update({ ... }); }
else { const { data: created } = await github.rest.issues.create({ ... }); }

(sandbox-registry.yml:93-106)

The marker comment lives in the issue body, not a label, so the lookup is a linear scan of every open issue's body text (github.paginate(issues.listForRepo, { state: 'open' }), sandbox-registry.yml:94-96) filtered to non-PR issues. This is simple and robust to renames, but means the workflow re-reads every open issue's body on every run — fine at repo scale, but a design detail worth knowing if the issue count ever grows large.

Pinning is a separate, best-effort GraphQL call:

try {
  await github.graphql(
    `mutation($id:ID!){ pinIssue(input:{issueId:$id}){ issue { number } } }`,
    { id: issue.node_id },
  );
} catch (e) { core.info(`pin skipped: ${e.message}`); }

(sandbox-registry.yml:108-116)

GitHub repos can pin at most 3 issues at a time. The try/catch means that if the issue is already pinned, or the repo already has 3 pins from something else, the workflow logs it and moves on rather than failing the run — the issue body still updates either way, it just may not stay pinned if something else occupies the other pin slots.