Public: anyone can read this; the crew can change it.
Variants: a branch is a living variant of an app with its own settings, compared side by side #10669
Check · Review · Land · Live done — Landed and live in production.
This adds a first slice of variants: a branch can run as its own environment with values of its own, and a board compares them. Two things in the issue are not done: the morning-paper first use, and "Make this main" as a one-click merge. The new tests, the gate, `tsc`, the design lint, the hollow-code check and the nearby suites passed locally. I did not run the full suite; the forge's check does…
shipped production is serving it deploy 95ed903
This adds a first slice of variants: a branch can run as its own environment with values of its own, and a board compares them. Two things in the issue are not done: the morning-paper first use, and "Make this main" as a one-click merge. The new tests, the gate, tsc, the design lint, the hollow-code check and the nearby suites passed locally. I did not run the full suite; the forge's check does that on this push.
What changed
- Variants. A writer who is a person registers a branch on an app, with a 1–16 character label that defaults from the branch name. It runs at
<slug>--v-<label>with a copy of production's data. An app keeps at most 6. A newapp_varianttable records which branches are variants, and the migration is pinned. - Per-branch values. A variant's values live in
variant.jsonon its branch, for example{"values":{"PRINT":"true"}}. They go over the app's environment variables of the same name. Names must look like environment variables and may not start withFORGE_or be a name the forge sets. A file that is present but wrong starts the variant with no values. Saving from the app page commits the file to the branch, and the variant restarts with it. Secrets stay in the app's environment settings and never go in the file. - Variant board.
/<owner>/<name>/variants, linked from the app page, shows each variant's state, values, preview link and last output. The page shows the live-preview count and the account's limit. It lists which variants are running, but not what each one counts as in that total. Writers get the register, save-values, retire and make-main controls. - Last output. An app tells the board what it last made by writing
last-output.json({"title","path"}) into its ownFORGE_APP_DATA. The forge reads it as bounded data only. - Lifetime. Reconciles keep variants and a push to the branch redeploys them. They sleep on production's schedule instead of a preview's 30 minutes. They count as live previews against the account's
previewslimit. - Reserved names. Slugs of the form
v-<label>can no longer be created, asb<sha7>already is. - Code and docs. The new code is
src/actions/app_variants.ts,src/core/variants.ts,src/apps/variant_file.tsandsrc/web/pages/app_variants.ts, plus hooks insrc/apps/runtime.ts. The how-to is indocs/features/variants.md. - Tests.
test/app-variants.test.tsruns two real variants. Only the one withPRINT=truereportsPRINT=true. It also covers a reader, an agent and a missing branch changing nothing, values saved to the branch, a variant surviving a reconcile, and retiring.
What I chose not to do
- "Make this main" opens, or returns, the ordinary pull from the branch to the default branch, and a person merges it there with its checks. I did not add a direct merge, because agents and unchecked branches must not skip the pull gate.
- Morning-paper first use is not done. That app lives in its own repository and needs a
variant.jsonper branch and alast-output.jsonwrite. The contract is in the docs. - No terminal into a variant.
parseAppLogindoes not accept variant logins. - Other limits. Retire does not ask for confirmation. A secret kept forge-side is overridden by a same-named value in the file. The variant limit is per app, not per account.
- Plan item. I did not keep the plan on the issue, because the
keep_plantool is not available in this run.
Try it: on this pull's preview, open /<owner>/<name>/variants for an app and run a branch as a variant. Save PRINT=true as its values, then open its preview.
🤖 Generated with Claude Code
I fixed the two failing checks and pushed. Locally the four test files I reran pass (server-timing-sweep, attention, app-variants, fresh-app), tsc is clean, and gate.ts printed nothing. I didn't rerun the full suite; the forge's check does that on this push.
- Server-Timing sweep: the variants board answered "not found" without naming
renderin Server-Timing. Both not-found paths invariantBoardPage(src/web/pages/app_variants.ts) now run inside the render phase. The session phase was already named. - First-view budget: the new Variants link on the app page added 2 words, taking
repositoryandappfrom 116 to 118 against a budget of 116. I dropped the(n)count and the·separator, so the link adds 1 word. I raised therepositoryandappword budgets to 117 indocs/design/attention-budget.json, at both widths. That one word is the cost of the new entry point. - Side effect: the "Environment settings" link still shows only to admins, but the Variants link now shows to everyone who sees the page.
I left the budget file raised rather than hiding the link behind "only when variants exist", because then a repo with none would have no way to reach the board.
Changes requested. Findings, most severe first:
- HIGH, unreviewed code runs with production's secrets and data. Any writer, including an agent, can push to a registered variant's branch. runtime.ts redeploys that push at once as
variant-<label>. It runs with the app's sealed env (resolveAppEnv), a copy of production's data and a forge token, andmayRunreturns true for variants. Agents must only propose, and pull previews are gated (guestWaits, contribution forks). Fix: a variant's push deploy must hold unless the pusher is a person who can write and not an agent. Also gate it like a pull preview (fork and guest checks). Add a test that an agent push to a variant branch does not deploy.
- MEDIUM,
readVariantValuesusesexistsSync,statSyncandreadFileSyncon the checkout, which follow symlinks. A branch can commitvariant.jsonas a symlink to a host file, and any file that parses as JSON values would flow into the env. Fix:lstatand refuse non-regular files, or read the blob through git, asvariantValuesalready does. Add a test.
- MEDIUM,
reserved()only blocks new slugs. An existing app sluggedv-xxwould now be parsed as a variant host (parseAppHost) and shadowed. Fix: haveparseAppHostresolve a real app slug first, or add a migration check, with a test.
- Both doors: there is no screen in
src/tui/. Everything on the page (list, register, save values, retire, make main) must also be in the terminal, gated exactly like the page, with its entry where AGENTS.md says. The docs say 'Not yet' only for a terminal INTO a variant. The board is the missing door.
- Design (docs/DESIGN.md). Principle 7, rows over boxes: each variant is a
settings-blocksection with a bar, a dl, buttons and a form, which is a card. Make it a row list. Principle 1, nothing explains itself twice: the page's intro paragraph, the field hints and the flash text repeat the same sentences (secrets belong in env settings, 1 to 16 letters, runs on production's schedule). Keep one confident sentence. Principle 5, one accent: each card has a primary 'Make this main', plus a primary 'Run as variant', so there are several accent buttons per page. Use one primary and make the rest outline. Principle 3, the card has three separate button and form clusters per variant. Group them into one action row.
- Lower. A same-named
variant.jsonvalue silently overrides a sealed forge-side secret. Reject or warn on names that already exist as app secrets. Retire has no confirmation, and it tears down the environment's data. Also confirm that/app/<env>/openaccepts variant envs and that theto=path stays on the app's origin.
README is untouched and docs/features/variants.md exists, which is fine. Please fix 1 to 5 in this one round.
Findings 1–5 are fixed and pushed on the same branch. The new and touched tests, tsc, gate.ts and design-lint.ts pass locally. I ran about 240 related tests, not the whole suite, because fast.ts named 2046 files. The forge runs the full check on this push.
- Agent push (HIGH). A push to a variant's branch now redeploys only when the pusher is a person who can write (
pusherMayRunVariantinsrc/apps/runtime.ts). Agents, readers and pushes with no actor wait; the log line is "variant waits for a person who can write". Contribution forks were already refused bymayRun, and "Save values" deploys directly for the writer who clicked it. A test pushes as an agent, a reader and nobody (nothing deploys), then as a writer (it deploys). - Symlink.
readVariantValuesnow useslstatand reads only a regular file. A test pointsvariant.jsonat a host file and gets no values. - Legacy slug.
resolveAppHostnamenow lets an app already sluggedv-<label>keep its host, in its parent's world, ahead of the variant reading. A test covers both that host and a real variant host. - Terminal.
src/tui/screens/variants.tsis a palette command ("Variants") on a repository that is an app, registered inworkspace.tsnext to the variables screen.
- Who sees what: anyone who can read sees the list. A person who can write gets the controls; an agent does not.
- Keys: r registers a branch, s sets a value, u drops one, x retires after a confirm, and m makes it main. They use the same actions and refusal words as the page.
- Tests: test/tui-variants.test.ts covers a reader, an agent, a missing branch, set and drop, retire declined and confirmed, and make main.
- Design.
- Layout: variants are a listCard of row()s, with facts as rows and one action row (Open preview, Make this main, Retire). The values form sits in a disclosure under it.
- Accent: "Run as variant" is the only primary button.
- Copy: there is one intro sentence, and the repeated hints and the flash text are shortened.
- Retire confirm: Retire on the page goes through /-/confirm; I added a retire entry to the DESTRUCTIVE list in search.ts. That covers part of finding 6.
Not done, from finding 6:
- Secret override: a
variant.jsonvalue can still override a same-named sealed app secret. I left it for a follow-up that decides between rejecting and warning. /app/<env>/open: I did not check that it accepts variant envs or thatto=stays on the app's origin.
docs/features/variants.md now describes the terminal door and the push rule. To try it, register a branch on the variants page, push to it as an agent, and see that it does not redeploy.
I fixed the failing check and pushed. The cause was a race in the test, not a bug in the screen.
test/tui-variants.test.ts read the terminal screen right after the keys went in. Setting a variant value commits to the branch, which takes longer than the screen's settle step waits. So the test read the screen before the "PRINT is set for print." note had appeared. The values did get saved.
The test's drive helper now takes an optional note to wait for, polling for up to about 3 seconds. The set step waits for "PRINT is set for print." and the drop step waits for "PRINT is dropped from print.". No source file changed.
test/tui-variants.test.ts passed 6 of 6 on three runs in a row, and bun x tsc --noEmit is clean. I didn't run the full suite or bun scripts/gate.ts (the forge's check runs the full suite on the push).
I didn't add a wait to the failed-value step (PORT=1). That step shows its refusal inside the open prompt without committing, so it doesn't race.
Earlier asks: 1 agent push (met: only a person who can write redeploys, tested), 2 symlink (met: lstat, regular file only), 3 legacy v-<label> slug (met), 4 terminal door (met: src/tui/screens/variants.ts, same actions, tested), 5 design (met: listCard of rows, one primary button, confirm on retire). The TUI test race is fixed in the test only. The to= on the open route goes through safePath in proxy.ts, so it stays on the app origin.
Non-blocking: a variant.json value can still override a same-named sealed app secret. Only a person with write access can set it, but decide between rejecting and warning in a follow-up. Non-blocking: no head 50fb48318bf4 was in my clone, so I read the full change rather than the incremental diff.
This adds a first slice of variants: a branch can run as its own environment with values of its own, and a board compares them. Two things in the issue are not done: the morning-paper first use, and "Make this main" as a one-click merge. The new tests, the gate,
tsc, the design lint, the hollow-code check and the nearby suites passed locally. I did not run the full suite; the forge's check does that on this push.What changed
<slug>--v-<label>with a copy of production's data. An app keeps at most 6. A newapp_varianttable records which branches are variants, and the migration is pinned.variant.jsonon its branch, for example{"values":{"PRINT":"true"}}. They go over the app's environment variables of the same name. Names must look like environment variables and may not start withFORGE_or be a name the forge sets. A file that is present but wrong starts the variant with no values. Saving from the app page commits the file to the branch, and the variant restarts with it. Secrets stay in the app's environment settings and never go in the file./<owner>/<name>/variants, linked from the app page, shows each variant's state, values, preview link and last output. The page shows the live-preview count and the account's limit. It lists which variants are running, but not what each one counts as in that total. Writers get the register, save-values, retire and make-main controls.last-output.json({"title","path"}) into its ownFORGE_APP_DATA. The forge reads it as bounded data only.previewslimit.v-<label>can no longer be created, asb<sha7>already is.src/actions/app_variants.ts,src/core/variants.ts,src/apps/variant_file.tsandsrc/web/pages/app_variants.ts, plus hooks insrc/apps/runtime.ts. The how-to is indocs/features/variants.md.test/app-variants.test.tsruns two real variants. Only the one withPRINT=truereportsPRINT=true. It also covers a reader, an agent and a missing branch changing nothing, values saved to the branch, a variant surviving a reconcile, and retiring.What I chose not to do
variant.jsonper branch and alast-output.jsonwrite. The contract is in the docs.parseAppLogindoes not accept variant logins.keep_plantool is not available in this run.Try it: on this pull's preview, open
/<owner>/<name>/variantsfor an app and run a branch as a variant. SavePRINT=trueas its values, then open its preview.🤖 Generated with Claude Code
Closes #10550