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:
- Cross-reference open PRs by branch —
github.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.
- Format each row —
ago()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`).
- 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.