Skip to content

feat(studio): productionalize the frontend-studio deploy package - #74633

Draft
stephenliang wants to merge 10 commits into
stagingfrom
stephen/studio-deploy-aio
Draft

feat(studio): productionalize the frontend-studio deploy package#74633
stephenliang wants to merge 10 commits into
stagingfrom
stephen/studio-deploy-aio

Conversation

@stephenliang

Copy link
Copy Markdown
Member

/frontend-studio deploys an S3 package to staging and test, but the HTML and the assets came from different places: Rails rendered asset tags from the local dist manifest while the files were served from the downloaded package. A machine that downloaded a package built elsewhere served HTML naming asset hashes it did not have, and every page load 404ed on its own script tags.

It also cannot ship to production at all. The package key is a Turborepo task hash, computing it needs node, and our production and levelbuilder frontends have no node. The rake task therefore skipped production outright, and the controller refused to render there, which made the rollout a code change rather than a switch.

What changes

  • The package carries the Vite manifest, and Rails reads the manifest from the directory the assets are actually served from. HTML and assets can no longer disagree.
  • A build environment writes a pointer object next to the package: key is the git hash of the committed frontend/ tree, body is the turbo hash of the package that tree needs. Machines without node read the pointer instead of running turbo. The pointer is written on every update, not only after a build, because a frontend/ change outside studio's build inputs rolls the git hash without rolling the turbo hash. A missing pointer raises and names the remedy, the same way the apps hotfix procedure does.
  • Unpacking a package no longer wipes the directory. The two newest packages stay on disk, so a browser holding the previous HTML can still fetch the assets that HTML names across a deploy. Older files are pruned from a recorded per-package file list, so the directory stays bounded. This is a studio-only override; the apps package still replaces its directory wholesale, because its filenames are not content-addressed.
  • The SPA catch-all no longer answers for paths under assets/. Asset URLs only come from the manifest, so a miss is a bad URL, never a client route, and answering with HTML let the CDN cache a page under a .js address. The route also keeps the trailing extension inside the path parameter, so a missing asset gets a 404 rather than a cross-origin error turned 500.
  • Production is gated on the DCDO flag frontend_studio_enabled, off in production by default. The branch is inert there until we flip it, and the flag is the kill switch afterwards.

Levelbuilder stays skipped. It ships as a later stack member once production is stable.

Review shape

This is an all-in-one branch for design review. It will be split into a PR stack before landing.

Follow-ups, not in this PR

  • Update the "How to Hotfix" gdoc: its apps clause should also cover frontend/ changes, which now have the same build-it-on-test-first requirement.
  • A Playwright smoke spec for /frontend-studio, which lives in the separate e2e-tests repo.

Verification

./tools/hooks/pre-commit clean over all ten changed files; new lib tests green (test_s3_packaging.rb 4 tests, test_turbo_s3_packaging.rb 7 tests, the latter building and unpacking real tarballs across three deploys to assert retention); frontend_studio_controller_test.rb green under spring testunit; route recognition for /frontend-studio/assets/app-abc123.js confirmed by hand via rails runner. Not run: any deploy against real S3.

🤖 Generated with Claude Code

stephenliang and others added 10 commits August 13, 2026 16:57
…rom the served dir

Rails rendered /frontend-studio HTML from the local dist manifest while the
assets were served from the S3 package. When a machine downloaded a package
built elsewhere, the HTML referenced asset hashes that did not exist on that
machine, and every page load 404ed on its own script tags.

The package now carries .vite/ so the manifest travels with the assets it
describes. The tar allowlists that one dotdir rather than including all
dotfiles, and decompression clears dotfiles too so a stale manifest cannot
survive an unpack. Outside development, Rails reads the manifest through the
dashboard/public/frontend-studio symlink, which is the same directory the
assets are served from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s absent

Our production and levelbuilder frontends have no node, so they cannot run
turbo to compute the studio package key. Today they skip the package
altogether, which is why /frontend-studio cannot ship to production.

A build environment now writes a pointer object next to the package: the key
is the git hash of the committed frontend/ tree, and the body is the turbo
hash of the package that tree needs. A machine without node reads the pointer
and downloads that package. The pointer is public-read, so the existing
no-credentials download path works unchanged.

The pointer is written on every update where the environment builds packages,
not only after a build. A frontend/ change outside studio's turbo inputs rolls
the git hash but not the turbo hash, and that new git hash still needs a
pointer to the unchanged package.

A missing pointer raises and names the remedy, mirroring the apps hotfix
procedure: build the commit on a build environment first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The controller refused to render in production, which made the production
rollout a code change rather than a switch. It also answered the SPA shell for
every path under /frontend-studio, including asset URLs, so a missing asset
returned 200 HTML that the CDN then cached under a .js address.

The route is now gated on the DCDO flag frontend_studio_enabled, which
defaults off in production and on everywhere else. That leaves production
inert until we turn it on and gives us a kill switch afterwards. Paths under
assets/ never fall through to the shell, because asset URLs only ever come
from the Vite manifest.

The rake update task no longer skips production. Levelbuilder is still
skipped; it ships after production is stable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unpacking a new package wiped the directory first. A browser that had already
loaded the previous HTML then lost the assets that HTML names, so any page
open across a deploy broke until it was reloaded.

The studio package now unpacks over what is already there. Asset filenames
carry a content hash, so two packages coexist; commit_hash and the Vite
manifest are overwritten, and the newest of those is the one we want. Each
unpack records the tarball's file list under .generations/, and files that no
longer appear in the newest two lists are deleted, so the directory stays
bounded. A directory that predates this scheme is recorded as the previous
generation, so the first deploy under it does not delete what is being served.

Retention is a studio-only override. The apps package still replaces its
directory wholesale, because its filenames are not content-addressed.

Tests cover package layout, dotfile handling, pointer resolution, and
retention across three deploys including the bootstrap case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eter

Rails read '/frontend-studio/assets/app-abc123.js' as a request for format js.
Cross-origin script protection then turned our 404 for a missing asset into a
500. Declaring the route format: false puts the whole path, extension
included, into :path and leaves the format as html.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The assets/ prefix check missed the files Vite copies from public/ to the
package root: favicon.ico, favicon.svg and mockServiceWorker.js. Those are
not hashed and not listed in the manifest, and the app links to the favicons
on every page load, so a miss on one returned the HTML shell with a 200 and
let the CDN cache a page under a .svg address. That is the case the check
exists to prevent.

A path with an extension always asks for a file from the package, so 404 on
any of them. No client route contains a dot: channel ids are urlsafe base64
with '=' stripped, course and unit names carry no dots, and lesson and level
positions are integers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
commit_hash sorts before .vite/ in the tarball, so an unpack that died in
its last few hundred kilobytes left the new marker next to the previous
package's manifest. The next run read the marker, logged "Package is
current", skipped the unpack, and served the previous build while reporting
success. Unpacking over the old package instead of replacing it is what
makes that state serve rather than fail.

Exclude the marker from the unpack and write it once the package is whole.
A run that dies part way now leaves the old marker, so the next run unpacks
again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reading through download_object(...).path dropped the only reference to the
tempfile before the read. The finalizer deletes the file when the object is
collected, so a garbage collection inside that window raised ENOENT and
failed the deploy for no reason. Reproduced with a forced GC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
package:studio:update skips levelbuilder before it builds a packager, so the
levelbuilder half of studio_pointer_mode? never ran. Leaving it in suggests
levelbuilder resolves a pointer, which it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The architecture doc still said Studio returns 404 in production because it
is pre-production, and that production serves the Vite build output as static
files. Deployed environments now unpack a prebuilt tarball and gate the app
on a DCDO flag. studio AGENTS.md asks for this doc to track the Rails
integration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant