feat(nevermore-cli): rebuild a place when its base place changes - #783
feat(nevermore-cli): rebuild a place when its base place changes#783Quenty wants to merge 20 commits into
Conversation
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
Deploy Resultsℹ️ No changed packages with deploy targets were discovered for this PR. · View logs Test Results
5 packages tested, 5 passed, 0 failed in 31.9s · View logs |
`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
|
Feedback from consuming this in StudioKoiKoi/egg-hunt-2026#456 (10-place game, watch-driven artist publishes). Validated against 1. 2. The two subcommands name the same monitor differently. 3. 4. Deploy metadata has no base place version. Two smaller notes, no action implied:
|
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
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
… 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
Adds a
watchfield todeploy.nevermore.jsonand 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-deploypackage, addsdeploy.nevermore.lock.jsonsobasePlace.versioncan track"saved"or"published"without builds losing reproducibility, and adds dotted target selectors likeintegration.places.hubso 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