|
| 1 | +--- |
| 2 | +title: 'Move the sandboxed preview to quarto-dev.github.io/q2-sandbox and free /q2 for the Q2 docs' |
| 3 | +date: 2026-10-26 |
| 4 | +description: 'quarto-dev.github.io/q2 serves the q2-sandboxed-preview iframe today, and the live quarto-hub.com bundle points at it. Move the sandbox to a new repo quarto-dev/q2-sandbox, repoint hub-client, retire the old Pages workflow, then publish the Q2 docs at /q2/ with a self-unregistering service worker that cleans up stale sandbox workers.' |
| 5 | +--- |
| 6 | + |
| 7 | +Strand: **bd-73lilznv**. Follow-up (out of scope here): **bd-gotwa715**, which |
| 8 | +gives the sandbox its own origin. |
| 9 | + |
| 10 | +## Overview |
| 11 | + |
| 12 | +### Current state (verified 2026-10-26) |
| 13 | + |
| 14 | +- `quarto-dev/q2` has GitHub Pages in **GitHub Actions mode** |
| 15 | + (`build_type: workflow`), so there is no `gh-pages` branch. Pages serves |
| 16 | + whatever artifact the last workflow run uploaded. |
| 17 | +- That workflow is `.github/workflows/deploy-sandboxed-preview.yml`. It builds |
| 18 | + `hub-client/quarto-hub-sandboxed-preview/` and deploys `dist/` on pushes to |
| 19 | + `main` that touch the sandbox package, `ts-packages/preview-renderer/**`, |
| 20 | + `resources/js|revealjs|attribution/**`, or the workflow itself. The |
| 21 | + `github-pages` environment allows deploys from `main` only (custom branch |
| 22 | + policy). |
| 23 | +- `Q2SandboxedPreviewIframe.tsx:67` defaults |
| 24 | + `VITE_Q2_SANDBOXED_PREVIEW_URL` to `https://quarto-dev.github.io/q2/`. |
| 25 | +- **Production** (`quarto-dev/quarto-hub-deployment`, |
| 26 | + `.github/workflows/build-and-deploy.yml`) does not override the variable. |
| 27 | + The live quarto-hub.com bundle contains `quarto-dev.github.io/q2/`. The |
| 28 | + `q2 preview --ui editor` embed (`npm run build:preview-embed` in |
| 29 | + `release-pipeline.yml`) uses the same default. |
| 30 | +- The iframe only loads for documents with `format: q2-sandboxed-preview`. |
| 31 | +- e2e, `scripts/local-dev.sh`, and `hub-client-e2e.yml` all override the URL, |
| 32 | + so this move doesn't affect them. |
| 33 | + |
| 34 | +### Decisions (user, 2026-10-26) |
| 35 | + |
| 36 | +- The new home is **`https://quarto-dev.github.io/q2-sandbox/`**, served from |
| 37 | + a new repo `quarto-dev/q2-sandbox`. The name is free (404 on the API as of |
| 38 | + today). |
| 39 | +- Breaking `q2-sandboxed-preview` in already-released q2 binaries is |
| 40 | + acceptable (early development). A changelog line is enough. |
| 41 | +- The new URL has the same origin as the docs (`quarto-dev.github.io`), which |
| 42 | + is a known weakness. It is no worse than today, and bd-gotwa715 tracks the |
| 43 | + fix. |
| 44 | + |
| 45 | +### Hazard: stale service workers at scope `/q2/` |
| 46 | + |
| 47 | +`quarto-hub-sandboxed-preview/src/entry.tsx:400` calls `initServiceWorker()` |
| 48 | +on every page load, including a direct top-level visit. The worker |
| 49 | +(`serviceWorker.ts`) intercepts **every** in-scope GET except `''`, |
| 50 | +`index.html`, `serviceWorker.js` and `q2-preview-assets/*`, and proxies it to |
| 51 | +the parent frame. A top-level navigation has no `clientId`, so the proxy |
| 52 | +answers **502 "No client for VFS proxy request"**. |
| 53 | + |
| 54 | +So anyone who ever opened `https://quarto-dev.github.io/q2/` directly would |
| 55 | +get 502s on every docs page except the landing page. Workers registered |
| 56 | +inside the quarto-hub.com iframe are partitioned by top-level site in current |
| 57 | +browsers and don't affect direct visits. |
| 58 | + |
| 59 | +Removing `serviceWorker.js` (a 404) does **not** unregister the old worker. |
| 60 | +The docs site must ship a replacement `serviceWorker.js` at the same path. |
| 61 | +Its job is to activate, unregister itself, and reload any open pages. |
| 62 | +Browsers check for a worker update on in-scope navigations, and the landing |
| 63 | +page (`index.html`, exempt from interception) always loads from the network, |
| 64 | +so recovery is quick. The landing page also gets a small unregister snippet |
| 65 | +as a backup. |
| 66 | + |
| 67 | +## Work items |
| 68 | + |
| 69 | +### Phase 1: Create `quarto-dev/q2-sandbox` and deploy to it |
| 70 | + |
| 71 | +The q2 workflow can't deploy Pages for another repo through |
| 72 | +`actions/deploy-pages`. **Recommended:** keep the build in q2, where the |
| 73 | +trigger paths already live, and push `dist/` to the `gh-pages` branch of |
| 74 | +`q2-sandbox` with a deploy key. The alternative, a workflow in `q2-sandbox` |
| 75 | +triggered by `repository_dispatch` that checks out q2 and builds, splits the |
| 76 | +build config across two repos. Confirm the choice before Phase 1 runs. |
| 77 | + |
| 78 | +- [ ] Create public repo `quarto-dev/q2-sandbox` with a README explaining |
| 79 | + that it's a deploy target and the source lives in |
| 80 | + `quarto-dev/q2/hub-client/quarto-hub-sandboxed-preview`. *(Outward-facing: |
| 81 | + confirm with the user before running `gh repo create`.)* |
| 82 | +- [ ] Generate an SSH deploy key pair. Add the public key to `q2-sandbox` |
| 83 | + with write access, and the private key to `quarto-dev/q2` as secret |
| 84 | + `Q2_SANDBOX_DEPLOY_KEY`. *(Confirm before writing secrets. The user |
| 85 | + may prefer to do this by hand.)* |
| 86 | +- [ ] Add a new workflow in q2, `.github/workflows/deploy-q2-sandbox.yml`. |
| 87 | + It uses the same triggers and build steps as |
| 88 | + `deploy-sandboxed-preview.yml`, adds `.nojekyll` to `dist/`, and |
| 89 | + pushes `dist/` to `q2-sandbox` `gh-pages` (force-push a single commit, |
| 90 | + e.g. `peaceiris/actions-gh-pages` with `external_repository` + |
| 91 | + `deploy_key`, pinned to a version at least 2 weeks old). It needs only |
| 92 | + `contents: read`, with no `pages`/`id-token`. |
| 93 | +- [ ] After the first push, enable Pages on `q2-sandbox` from the `gh-pages` |
| 94 | + branch (`gh api -X POST repos/quarto-dev/q2-sandbox/pages ...`). |
| 95 | +- [ ] Verify that `https://quarto-dev.github.io/q2-sandbox/`, |
| 96 | + `/q2-sandbox/serviceWorker.js` and a `q2-preview-assets/` chunk all |
| 97 | + return 200 with the right content types. |
| 98 | + |
| 99 | +Run the old and new workflows side by side until Phase 3. `/q2/` keeps |
| 100 | +serving the sandbox until production stops using it. |
| 101 | + |
| 102 | +### Phase 2: Repoint hub-client (q2 PR) |
| 103 | + |
| 104 | +- [ ] `hub-client/src/components/render/q2-sandboxed-preview/Q2SandboxedPreviewIframe.tsx:67`: |
| 105 | + change the default to `https://quarto-dev.github.io/q2-sandbox/` and |
| 106 | + update the comment above it. |
| 107 | +- [ ] `assetPolicy.test.ts`: update `PAGES_SCOPE` (line 20) and `base` (line |
| 108 | + 113). Check for any other `/q2/` literals the tests depend on. |
| 109 | +- [ ] Docs and notes: rewrite `.github/workflows/github-pages.md` to cover |
| 110 | + the new split (q2 Pages = docs, q2-sandbox = sandbox). Update |
| 111 | + `claude-notes/designs/q2-sandboxed-preview-separate-domain.md` |
| 112 | + (architecture diagram and deployment bullet) and |
| 113 | + `.claude/skills/snyk-pr/references/katex.md` (workflow name). Leave |
| 114 | + the historical 2026-09-01 port plan as it is. |
| 115 | +- [ ] Add a `hub-client/changelog.md` entry. |
| 116 | +- [ ] Origin pinning doesn't change: `sandboxTargetOrigin()` derives |
| 117 | + `https://quarto-dev.github.io` from both URLs. |
| 118 | +- [ ] Tests: `npm run test` in hub-client (assetPolicy unit tests) and the |
| 119 | + `q2-sandboxed-preview` e2e spec, which uses the same-origin copy and |
| 120 | + should be unaffected. |
| 121 | +- [ ] Merge to `main`. *(Push and PR only with explicit go-ahead.)* |
| 122 | + |
| 123 | +### Phase 3: Production cutover |
| 124 | + |
| 125 | +- [ ] Trigger a quarto-hub.com rebuild from `quarto-hub-deployment` so it |
| 126 | + picks up the new default from q2 `main`. *(Confirm before triggering |
| 127 | + a production deploy.)* |
| 128 | +- [ ] Verify the live bundle contains `quarto-dev.github.io/q2-sandbox/` and |
| 129 | + no longer contains `quarto-dev.github.io/q2/`, using the same |
| 130 | + curl-and-grep against `https://quarto-hub.com/` as before. |
| 131 | +- [ ] Smoke test: open a `format: q2-sandboxed-preview` doc on quarto-hub.com. |
| 132 | + The iframe should load from `/q2-sandbox/`, an image asset should |
| 133 | + render through the SW proxy, and the console should be clean. |
| 134 | + |
| 135 | +### Phase 4: Retire the old workflow and publish docs at `/q2/` |
| 136 | + |
| 137 | +- [ ] Delete `.github/workflows/deploy-sandboxed-preview.yml`. Leave Pages |
| 138 | + on `quarto-dev/q2` in GitHub Actions mode. |
| 139 | +- [ ] Add a docs deploy workflow (e.g. `.github/workflows/deploy-docs.yml`) |
| 140 | + that builds q2, runs `cargo xtask stage-doc-examples`, renders `docs/` |
| 141 | + with **Q2** (`q2 render docs/`, not Q1), and deploys `docs/_site/` via |
| 142 | + `upload-pages-artifact` + `deploy-pages` to the existing |
| 143 | + `github-pages` environment. Trigger it on `main` pushes touching |
| 144 | + `docs/**`, plus `workflow_dispatch`. Decide during implementation |
| 145 | + whether a pinned q2 release binary or a source build is the better |
| 146 | + renderer for CI; the release pipeline's docs-embed step is prior art. |
| 147 | + Set `site-url: https://quarto-dev.github.io/q2/` if the site needs it |
| 148 | + (sitemap, llms.txt). |
| 149 | +- [ ] Add a self-unregistering `serviceWorker.js` to the docs output root |
| 150 | + (e.g. `docs/serviceWorker.js` listed under `project.resources`): |
| 151 | + |
| 152 | + ```js |
| 153 | + self.addEventListener('install', () => self.skipWaiting()); |
| 154 | + self.addEventListener('activate', (e) => e.waitUntil((async () => { |
| 155 | + await self.registration.unregister(); |
| 156 | + for (const c of await self.clients.matchAll({ type: 'window' })) c.navigate(c.url); |
| 157 | + })())); |
| 158 | + ``` |
| 159 | + |
| 160 | + Add a comment explaining why it exists and when it can go (no earlier |
| 161 | + than about 2027-04). |
| 162 | +- [ ] Add a backup unregister snippet to the docs landing page: on load, |
| 163 | + unregister any `navigator.serviceWorker` registration whose scope is |
| 164 | + `/q2/`. |
| 165 | +- [ ] Verification: in a browser profile, open the **old** sandbox page at |
| 166 | + `/q2/` top-level before the docs deploy, so its worker registers. |
| 167 | + Deploy the docs, then visit a docs subpage. Expect at most one 502 |
| 168 | + followed by recovery, or none if the landing page is visited first. |
| 169 | + Confirm in `chrome://serviceworker-internals` that the registration |
| 170 | + is gone. If no pre-deploy profile was captured, record that the step |
| 171 | + was skipped. |
| 172 | + |
| 173 | +## Out of scope |
| 174 | + |
| 175 | +- Separate origin for the sandbox: **bd-gotwa715**. |
| 176 | +- Stopping `entry.tsx` from registering the worker when not framed. This is |
| 177 | + noted on bd-gotwa715. It matters less once `/q2-sandbox/` has no |
| 178 | + first-party visitors besides us. |
| 179 | + |
| 180 | +## Progress log |
| 181 | + |
| 182 | +- **2026-10-26: Phase 1 GitHub setup done.** Created `quarto-dev/q2-sandbox` |
| 183 | + (public), added a write deploy key (title `quarto-dev/q2 |
| 184 | + deploy-q2-sandbox.yml`), and set secret `Q2_SANDBOX_DEPLOY_KEY` on |
| 185 | + `quarto-dev/q2`. The local key was deleted right after. The first |
| 186 | + `gh-pages` commit was pushed **by hand** from a local `dist/` build (with |
| 187 | + `.nojekyll`), and Pages is enabled from `gh-pages` /. Verified 200 with |
| 188 | + correct content types for `/`, `index.html`, `serviceWorker.js` |
| 189 | + (byte-identical to the local build), the JS/CSS chunks and a KaTeX woff2. |
| 190 | + The CI workflow `deploy-q2-sandbox.yml` (plain git + deploy key, no |
| 191 | + third-party action) is written and passes actionlint. It has not run yet |
| 192 | + and first runs when this branch merges to `main`. |
| 193 | +- **2026-10-26: Phase 2 code done on branch |
| 194 | + `braid/bd-73lilznv-move-sandboxed-preview-quarto`.** Changed the default |
| 195 | + URL, updated assetPolicy tests (plus a `/q2/` vs `/q2-sandbox/` |
| 196 | + scope-prefix case), `github-pages.md`, the design doc and the snyk katex |
| 197 | + reference, and marked `deploy-sandboxed-preview.yml` TRANSITIONAL. It |
| 198 | + keeps running until Phase 4. hub-client `npm run test` passes |
| 199 | + (1686/1686) and `tsc -b` is clean. |
0 commit comments