Skip to content

feat(nevermore-cli): rebuild a place when its base place changes - #783

Open
Quenty wants to merge 20 commits into
mainfrom
users/quenty/nevermore-cli-publishing-improvements
Open

feat(nevermore-cli): rebuild a place when its base place changes#783
Quenty wants to merge 20 commits into
mainfrom
users/quenty/nevermore-cli-publishing-improvements

Conversation

@Quenty

@Quenty Quenty commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Adds a watch field to deploy.nevermore.json and a --watch <register-url> flag, so a place rebuilds automatically when the Roblox base place it was built from gains a new version — in CI by dispatching the workflow that place names, and locally by holding a stream and rebuilding in the terminal instead of on a runner.

Along the way this extracts deploy config parsing into a @quenty/nevermore-deploy package, adds deploy.nevermore.lock.json so basePlace.version can track "saved" or "published" without builds losing reproducibility, and adds dotted target selectors like integration.places.hub so a watch can rebuild exactly the place that moved.

Nevermore itself does not use the watch service; it ships no endpoint and the address is passed in, since this is a public repo.

https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm

📦 Published PR as canary version: Canary Versions

✨ Test out this PR locally via:

npm install @quenty/access@1.3.8-canary.783.30686127768.0
npm install @quenty/gameconfig@12.62.4-canary.783.30686127768.0
npm install @quenty/gameproductservice@14.66.6-canary.783.30686127768.0
npm install @quenty/gameversionutils@3.6.2-canary.783.30686127768.0
npm install @quenty/nevermoreclimanifest@1.8.1-canary.783.30686127768.0
npm install @quenty/saveslot@2.6.3-canary.783.30686127768.0
npm install @quenty/nevermore-cli@4.42.1-canary.783.30686127768.0
npm install @quenty/nevermore-cli-helpers@1.16.2-canary.783.30686127768.0
npm install @quenty/nevermore-deploy@1.0.1-canary.783.30686127768.0
npm install @quenty/studio-bridge@0.17.2-canary.783.30686127768.0
# or 
yarn add @quenty/access@1.3.8-canary.783.30686127768.0
yarn add @quenty/gameconfig@12.62.4-canary.783.30686127768.0
yarn add @quenty/gameproductservice@14.66.6-canary.783.30686127768.0
yarn add @quenty/gameversionutils@3.6.2-canary.783.30686127768.0
yarn add @quenty/nevermoreclimanifest@1.8.1-canary.783.30686127768.0
yarn add @quenty/saveslot@2.6.3-canary.783.30686127768.0
yarn add @quenty/nevermore-cli@4.42.1-canary.783.30686127768.0
yarn add @quenty/nevermore-cli-helpers@1.16.2-canary.783.30686127768.0
yarn add @quenty/nevermore-deploy@1.0.1-canary.783.30686127768.0
yarn add @quenty/studio-bridge@0.17.2-canary.783.30686127768.0

Quenty added 11 commits July 30, 2026 14:18
deploy.nevermore.json's basePlace.version only accepted an exact version
number, which holds the base place still. Two teams wanted the opposite:
follow the base place as Studio moves it, but be precise about which kind
of movement counts.

It now also accepts "published" (newest version published live, so Studio
saves never leak into a deploy) and "saved" (newest version of any kind,
for Team Create places where saving is the handoff). Both resolve fresh on
every deploy against the place's version history, which the Assets API
pages newest-first — so it stays cheap on places with tens of thousands of
versions, and refuses to guess rather than silently pinning stale content
if that ordering ever changes.

"deploy version upgrade" now skips and reports keyword pins instead of
freezing them to a number, and resolves the latest published version
rather than the latest revision of any kind, which is what it already
documented. "deploy version promote" copies keyword pins across targets
unchanged.

Claude-Session: https://claude.ai/code/session_01FnAPc3dfRaRc1G2wtnFtwt
… its own package

deploy.nevermore.json is read from six places across five files in the CLI,
plus a seventh path that parses it by hand. Every consumer that grows a new
rule — validation, defaulting, base place version resolution — is another
chance for two call sites to disagree about what the file means.

Moves the schema, validation, and target resolution into
@quenty/nevermore-deploy-config so there is one owner of the format. The
package is schema and policy only: no network, no yargs, and no file access
beyond the config itself, which keeps it trivially testable and lets other
repos read a deploy config without depending on the whole CLI.

No behavior change — the module moved and its importers now name the
package instead of a relative path.

Claude-Session: https://claude.ai/code/session_01FnAPc3dfRaRc1G2wtnFtwt
A "saved"/"published" base place pin resolves fresh on every deploy, so
two builds of the same commit could merge against different Studio
content with nothing in git recording it. An omitted pin was worse: it
recorded nothing anywhere, so that commit could never be re-deployed
byte-identically.

Adds a committed lock file beside deploy.nevermore.json holding what each
pin resolved to, keyed by base place id. The config keeps stating intent
("follow published") and the lock carries the fact ("that was v158"), so
rolling a base place forward no longer has to overwrite the intent, and
the move shows up as a reviewable diff. No timestamps — git already
records when an entry changed, and ties it to an author and a commit.

All of it goes through one BasePlaceResolver, constructed once per run by
a single factory, so deploy run, batch deploy, test, and batch test cannot
drift apart on lock policy. It flushes from disposeAsync, which every
entry point already runs in a finally, and dedupes lookups per place id so
a 10-wide multi-place fan-out costs one network call. --frozen-lockfile
refuses to resolve anything the lock does not already answer; it is off by
default, including in CI, so a repo without a committed lock keeps
building.

Also fixes a silent failure this made visible: a job context with no Open
Cloud client returned early from the base place merge, so `nevermore test`
without --cloud reported success on a place missing all of its
Studio-authored content. It now builds a client and errors instead.

Claude-Session: https://claude.ai/code/session_01FnAPc3dfRaRc1G2wtnFtwt
…view

Five real defects in the lock work, the first two of which lost or
corrupted lock entries on paths that run by default:

BasePlaceResolver awaited the lock file read before storing the tracked
lock in its map, so two concurrent resolves for one package each built a
separate copy and whichever finished second replaced the first. A
multi-place deploy fans out ten wide against a single package directory,
so all but one place merged content that was never recorded. The map now
holds the in-flight promise.

The in-flight lookup cache was keyed by place id alone, so a base place
tracked as "saved" was handed the "published" answer if anything had
already asked for that place — and the lock then claimed the wrong
version type. The version type is now part of the key. Since one entry
holds one version type, a base place tracked as both in a single run is
now a clear error rather than a lock that churns forever and can never
satisfy --frozen-lockfile.

deploy version upgrade compared only version numbers, so switching a pin
from "published" to "saved" while the number happened to be unchanged
wrote nothing, and every later frozen build failed telling the user to
run the command that had just refused to help. It compares the version
type too. It also now runs when a config has no keyword pins left, so
converting the last one back to a number clears its orphaned entry.

The newest-first assertion never ran on the first entry of a page, which
is the entry a "saved" lookup returns — the one case it was written to
protect. The whole page is checked before anything is returned.

The lock flush ran first in disposeAsync and unguarded, so a failed write
skipped every artifact cleanup below it and replaced the real deploy error
with its own. It runs last and best-effort.

Also documents two things the branch had wrong: aggregated batch test (the
default) combines every package into one place and cannot merge a
basePlace, which now warns for affected packages instead of silently
dropping their Studio content; and keyword pins are resolved once and then
held by the lock, not "fresh on every deploy" as three docstrings claimed.

Claude-Session: https://claude.ai/code/session_01FnAPc3dfRaRc1G2wtnFtwt
…config

It owns the lock file and base place version resolution as well as the
config schema, so "-config" undersold it and made the import line longer
than the thing it named.

Claude-Session: https://claude.ai/code/session_01FnAPc3dfRaRc1G2wtnFtwt
Adds a "watch" field to deploy.nevermore.json and a --watch flag that
registers with a watch service, so a place rebuilds when the Roblox base
place it was built from gains a new version.

In CI the watch dispatches the workflow named by the place's "watch"
field. Locally it registers a notify action instead and holds a
websocket, rebuilding in the terminal rather than on a runner, and falls
back to polling Open Cloud when the service is unavailable or cannot
read the place. --watch takes the register endpoint URL ending in the
lease; Nevermore ships no endpoint of its own, since this is a public
repo and the address is deployment-specific infrastructure.

Also adds dotted target selectors (integration.places.hub) so a watch
can rebuild exactly the place that moved, and --refresh-base-place,
without which a watch-triggered build would redeploy the version it was
already locked to and the reload would silently do nothing.

No baselineVersion is ever sent. The service writes versions as the
asset-delivery content hash while the lock file holds the Open Cloud
place version, and the two can never compare equal — so supplying one
read as drift and rebuilt on every registration. Omitting it lets the
first poll adopt, which is what a baseline was for.

Claude-Session: https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm
…nt tail

The unobservable check only ran on the `ready` snapshot, which arrives at
connect. A connection opened before the service's first failed poll sees
a clean snapshot, and the next `ready` is an hour away — so a base place
the service cannot read looked healthy for an hour while the watch did
nothing.

The service tails `poll-failed` on the same socket, and only logs it once
several polls have failed in a row, so it already means more than a blip.
Both directions now feed one set, and the watch falls back to polling
here as soon as nothing on the stream can fire.

Claude-Session: https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm
…ends

The watch service reads version history with the caller's key when one is
shared, which changes three things at once, so --watch-share-api-key now
decides all three rather than being stored and ignored.

A private base place becomes readable. "saved" becomes watchable, and is
skipped with a message naming the flag rather than as an unsupported
version type. And the service reports versions as the same place version
integer the lock file records, so a baseline can finally be sent — which
matters most for a package that did not deploy this run, whose lock names
an older version and whose already-happened change the first poll would
otherwise adopt and forget. It is declared as baselineVersionKind, so a
vocabulary mismatch is refused at registration instead of arriving later
as one unexplained rebuild.

Reading anonymously nothing changes: no baseline is sent, because a place
version could never match a content hash.

Also stops volunteering workflow advice on a 422 for a monitor that
dispatches no workflows. 422 now covers a missing scope, a "saved" watch
without a key, and a mismatched baseline as well, and the service names
which — appending "Referenced: ." under a message about an API key scope
only obscured it.

Claude-Session: https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm
`--watch` outside CI needed a GitHub token exported by hand, even though
`gh` on the same machine was already holding one. `--watch-use-gh-auth`
reads it, so a local watch can stream without setting anything up.

Opt-in rather than automatic. Registering sends the token to the watch
service and creates a monitor against the repository, so the credential
has to be one the caller handed over — setting an environment variable is
handing it over, reading whatever `gh` happens to be logged into is not.
Nearly every developer machine has a logged-in `gh`, so defaulting it on
would quietly turn a command that watches locally into one that ships a
token.

Nothing is cached: `gh` owns its own refresh, and a copy under
~/.nevermore would be a stale duplicate expiring behind everyone's back.
Output that cannot be a token is refused rather than sent, so a logged-out
`gh` printing a notice does not surface later as a confusing 401.

Also documents `universe.place:read` in the login help, which is the scope
the watch service needs to observe a private base place and the one thing
`nevermore login` never mentioned.

Claude-Session: https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm
The worst was self-inflicted: an earlier refactor swept `packagePath`
out of `buildPlaceAsync` as well as the watch candidate it was aiming
at. The field was optional, so nothing complained, and every package in
a batch fell back to `process.cwd()` — collapsing onto one build cache
key, so one package's place file could be uploaded to another's place,
and writing every base place pin into a single lock file at the repo
root. It is required now, which is what would have caught it.

`baselineVersionKind` was built and tested but never copied into the
request body, so it never reached the service. That is the field the
service ignores a baseline without on the exact transition it exists
for — the first registration that supplies an Open Cloud key. The test
that should have caught it asserted against a fake registry rather than
the wire, and its fixture omitted the field.

A dryrun registered under the same monitor name as a real run. Since it
cannot resolve the Open Cloud key — that happens after it returns — it
would strip the key from the live monitor, move every source back to the
anonymous driver and its content-hash vocabulary, drop the baselines,
and delete any "saved" watch outright. It now registers a monitor of its
own, and no longer claims that firing "does not touch the deploy" when
the dispatched workflow runs a real one.

Also: batch no longer registers watches after a failed deploy, where a
baseline would name a version that never shipped and permanently mask
the failure; a throw from the stream falls back to polling instead of
ending the run; reconnect backoff applies to "come back now" closes too,
so a server stuck emitting one is not hammered forever; a retirement on
a short-lived connection is reported rather than silently retried; and a
`ready` frame that omits a watch entirely is treated like one that
cannot be read, since neither can ever produce a notification.

Removes dead code left by three refactor rounds.

Claude-Session: https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deploy Results

ℹ️ No changed packages with deploy targets were discovered for this PR. · View logs

Test Results

Package Status Try it
@quenty/nevermoreclimanifest ✅ Passed (12/12) (786ms) Open | Play
@quenty/gameconfig ✅ Passed (12/12) (2.1s) Open | Play
@quenty/access ✅ Passed (311/311) (4.7s) Open | Play
@quenty/gameproductservice ✅ Passed (88/88) (2.4s) Open | Play
@quenty/saveslot ✅ Passed (190/190) (16.0s) Open | Play

5 packages tested, 5 passed, 0 failed in 31.9s · View logs

@Quenty Quenty self-assigned this Aug 1, 2026
@Quenty Quenty added the canary This makes auto-it add a canary to the PR label Aug 1, 2026
`SAVED_WATCH_SUPPORTED` was named as the switch for enabling "saved"
watches; no such constant exists, and the rule is whether the run shares
an Open Cloud key. `--watch <duration|url>` survived in two tables after
the flag became URL-only, along with a line about a bare duration naming
an environment variable that is no longer read. And the package now
reaches the outside world through three ports, not two.

An architecture review found these by reading the docs against the code.
Documentation that names a symbol which isn't there is worse than none:
the next person greps for it and concludes they misread the design.

Also promotes the streaming-to-polling fallback reason from verbose to
info. It decides whether the watch spends this machine's Open Cloud quota
and how late a change is noticed, and the usual cause is one flag away
from being fixed — a user who never sees why cannot choose.

Claude-Session: https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm
A build made from a `"published"` base place pin had no way to say which
upstream Studio content it contained. The pin resolves at deploy time and
the base place keeps moving afterwards, so once the place is running the
answer is unrecoverable — the merge is the only moment it is known.

`BasePlaceVersion` and `BasePlaceId` now travel with the existing commit,
branch, timestamp and package version, and `NevermoreCLIManifestUtils`
reads them back as numbers. That makes "is the live game running the
latest Studio edit?" answerable in game: compare the stamped version
against the base place's current one.

Both are absent on a build that merged no base place, and the id travels
with the version because a version number means nothing without knowing
which place it counts. Stringified on the wire like the other ids, since
Lune serializes number attributes as float32 and silently corrupts
anything above 2^24.

Also drops the `?? process.cwd()` fallbacks behind `packagePath`, which
is required now. They were the same silent-default shape as the batch
cache-key bug: correct in the single-package case and quietly wrong
everywhere else.

Claude-Session: https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm
@Quenty

Quenty commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Feedback from consuming this in StudioKoiKoi/egg-hunt-2026#456 (10-place game, watch-driven artist publishes). Validated against 4.42.1-canary.783.30683193716.0. Four things, roughly in severity order.

1. isCI() should be an explicit flag, not detection. deploy run picks dispatch-vs-notify on !!process.env.GITHUB_ACTIONS. It is correct today, but the failure mode is asymmetric: guess "notify" in CI and the run holds a stream and never exits — a hung job that burns the full 6-hour timeout, with no error to read. Anything that runs the CLI in an automated context that is not GitHub Actions (a container, another CI, act, a cron box) hits exactly that. A --watch-mode dispatch|notify flag with detection as the default keeps the ergonomics and makes the hang unreachable. Cheap insurance for a silent, expensive failure.

2. The two subcommands name the same monitor differently. batch deploy uses monitorName: targetName (demo); deploy run uses `${packageName}/${watchedTarget}` (egg-hunt-2026/demo). A repo that registers from both paths — plausible, since one is "deploy everything" and the other is "rebuild the place that moved" — gets two monitors watching the same base places, so one artist publish dispatches twice, and each rebuild races the other's lock write. We worked around it by only ever registering from the batch path, but the consumer has to notice the mismatch first. One naming rule in watch-config.ts would remove the hazard.

3. batch deploy returns before registering when nothing changed. The early return on batchTargets.length === 0 sits above the if (watchOption) block, so a heartbeat run that diffs clean renews nothing — the lease quietly lapses and watching stops with no failure anywhere. The design doc calls this out ("--watch must register every target with a watched dependency, not only the ones this run happened to publish"), but the code still permits it. --all is the workaround; a warning when --watch is passed and the run short-circuits would make it self-correcting.

4. Deploy metadata has no base place version. buildDeployMetadataAttributes carries commit/branch/timestamp/packageVersion. The artist workflow this feature exists for asks the running game to show "the place version source of truth (not the published version)" so an artist can confirm the build they joined contains what they just published. The resolver already knows that number at build time and the lock records it, so it looks like a field away — without it, the loop the feature closes still can't be verified in-game.

Two smaller notes, no action implied:

  • npx @quenty/nevermore-cli@<version> inside a repo that pnpm-links the CLI runs the linked local copy and ignores the version spec. That cost me a round of measurements against what I thought was 4.42.0 and was actually the working tree. Not your bug, but it will bite anyone testing a canary from a linked repo, so it may be worth a line in the README.
  • The --refresh-base-place hint after a lock move ("Commit the change, or the next build without --refresh-base-place will rebuild the previous base place version and revert this deploy") is the single most useful line in the output. It is what told us the CI lock commit was load-bearing rather than bookkeeping.

https://claude.ai/code/session_0161agJAGwyYQvK2FnuYCUnc

Quenty added 2 commits July 31, 2026 21:35
They stubbed the git commands but not the Actions environment, and the
environment wins. On a pull request GitHub sets GITHUB_REF_NAME to a
merge ref, which resolution deliberately refuses — so all five passed
locally, where nothing sets it, and all five failed in CI. Clearing both
variables makes them exercise the git fallback they were written for.

Also formats two files the linters flagged: HasSaveSlots.lua, whose
stylua run was never committed, and base-job-context.ts, which I edited
after its last prettier pass.

Claude-Session: https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm
`pnpm --filter './tools/**'` matched nothing and exited 0, so `npm run
test:ts`, `lint:ts` and `build:ts` reported success having run no tests
at all. npm spawns scripts through cmd.exe on Windows, which does not
strip single quotes, so pnpm received the quotes as part of the pattern.

Double quotes are stripped by both cmd.exe and sh, so the same script
works either way. The glob was never wrong.

Claude-Session: https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm
Quenty added 2 commits July 31, 2026 22:05
Feedback from a 10-place game driving watch rebuilds from artist publishes.
Three separate ways the feature misbehaves in a repo larger than the one it
was developed against.

`--watch-mode` makes the dispatch-or-notify choice settable. It was picked
from GITHUB_ACTIONS alone, which is correct on Actions and correct on a
laptop but wrong everywhere else automated — a container, another CI, `act`,
a cron box. Those read as local, and notifying holds a stream nothing will
ever close: no error, no output, just a job burning its timeout. Detection
stays the default; the flag makes the hang reachable only on purpose.

Both commands now derive the monitor name from one function. `batch deploy`
named it for the target and `deploy run` for the package and target, so a
repo registering from both paths — plausible, since one is "deploy
everything" and the other "rebuild the place that moved" — got two monitors
watching the same base places. One publish dispatched twice and each rebuild
raced the other's lock write. `batch deploy` writes one monitor per package
to match, which is the right unit anyway: the lock file supplying a watch's
baselines is per package.

A `batch deploy --watch` that finds nothing changed now registers before
returning. The early return sat above the watch block, so a heartbeat run
that diffed clean renewed nothing and the lease quietly lapsed — watching
stopped with no failure anywhere to notice. Renewal never needed the run to
deploy: it derives from committed config plus the lock.

Claude-Session: https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm
…d all

`npx @quenty/nevermore-cli@<version>` inside a repo that pnpm-links the CLI
runs the working tree instead, and nothing reports the substitution — so a
canary measured that way is a measurement of your own build. Cost a consumer
a round of measurements against a version they were never running.

Claude-Session: https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm
Quenty added 3 commits July 31, 2026 22:33
… the flag

A review argued the field should be `rebuildWorkflow`, since it holds a
workflow path and `--watch` is the flag rather than the thing being watched.
That reading is reasonable and wrong: the shared name is deliberate. The cloud
path dispatches the workflow and a local run rebuilds in the terminal instead,
but they are one feature with one vocabulary, and naming the field for the
cloud mechanism would split that in two.

Written down so the next reader reaches the intent before the rename.

Claude-Session: https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm
"Which places can be watched" was written out three times: once in
`buildWatchPlan` for a registration, once in the local watch builder, and once
as a field test in `batch deploy`. The three have to agree, and a place counted
watchable by one and skipped by another is a rebuild that silently never
happens.

`checkBasePlaceWatchable` is now the only copy. It returns the resolved
`versionType` as well, since defaulting an absent version to "published" is
part of the same rule and each caller was re-deriving it.

The local watch still differs in exactly one way — it does not require a
`watch` workflow, because it dispatches nothing and rebuilds in the terminal —
and that difference is now the only thing the local path spells out for itself.

Claude-Session: https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm
Four maps were keyed by the same watch name — the entry, what it was built
from, what token it was settled at, and whether the service can read it — so
"which watches exist" was answerable four ways. Every field is a fact about the
same thing; one record per watch removes the possibility of them disagreeing.

No behavior change. The empty-entries case is still not treated as
unobservable, which `.every()` would otherwise report as vacuously true.

Claude-Session: https://claude.ai/code/session_01Vu4orfAZgH8wZQb9qegSDm
@Quenty
Quenty deployed to integration August 1, 2026 05:35 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

canary This makes auto-it add a canary to the PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant