Skip to content

Commit a3e9458

Browse files
authored
feat: Add better deploy UX including preview and stuff (#694)
1 parent 5cba156 commit a3e9458

7 files changed

Lines changed: 456 additions & 85 deletions

File tree

docs/_AI_INDEX.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ This file is an index for AI agents. The `_` prefix keeps it out of Docusaurus.
3737
|-----|-------------|
3838
| [intro.md](intro.md) | Getting started with Nevermore, why use it, key packages |
3939
| [install.md](install.md) | Installation methods: NPM + CLI, existing Rojo projects, plugins |
40+
| [deploy.md](deploy.md) | `nevermore deploy`: login, `deploy init`, `deploy run`, config schema, flag reference, common workflows |
4041
| [architecture/](architecture/index.md) | Architecture: workspace layout, design philosophy, ServiceBag, dependency injection |
4142
| [architecture/patterns.md](architecture/patterns.md) | Core patterns: Maid, BaseObject, Binder, Rx, Brio, Blend, AdorneeData, TieDefinition |
4243
| [build.md](build.md) | Contributing: local setup, tools, versioning, custom Rojo |

docs/deploy.md

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
---
2+
title: Deploying with the CLI
3+
sidebar_position: 3
4+
---
5+
6+
# Deploying places with `nevermore deploy`
7+
8+
`nevermore deploy` builds a Rojo project, uploads it to a Roblox place via the [Open Cloud API](https://create.roblox.com/docs/cloud), and (optionally) publishes the new version so players see it. `nevermore test` is built on the same pipeline, so everything that runs in a Roblox place goes through this command.
9+
10+
This guide walks you from zero to your first uploaded place. For advanced features (merging with a Studio-authored base place, smoke tests, batch deploys, CI), see [Integration Testing](testing/integration-testing.md).
11+
12+
:::tip New to Nevermore?
13+
Start with the [Intro](intro.md) for an overview and [Install](install.md) for setting up Node, Rojo, and the Nevermore CLI itself. This guide assumes the CLI is already on your `PATH`.
14+
:::
15+
16+
## When should I use this?
17+
18+
For most Roblox projects, especially solo work and small teams, you don't need this. Open Roblox Studio, click **Save to Roblox** or **Publish to Roblox**, and you're done. That's normally the right answer.
19+
20+
You probably want `nevermore deploy` when:
21+
22+
- Your code lives in a git repository and Studio is just where you preview it. Deploys should come from the same commit history that reviews and tests run against, not from whichever developer's Studio happens to be open.
23+
- More than one programmer ships to the same place. Studio's publish flow is last-writer-wins, and a CLI deploy from CI gives you one path from "merge to main" to "live in game", traceable to a specific commit.
24+
- You want one config to drive both tests and deploys. `nevermore deploy` and `nevermore test` both read `deploy.nevermore.json`, so the place you smoke-test against on every PR is configured exactly like the one you ship to. See [Test Infrastructure](testing/testing.md).
25+
- You want CI to gate releases. Batch deploys plug into PR checks, so a deploy only runs after lint, tests, and smoke tests pass, and shows up as a PR comment instead of an ad-hoc Studio session.
26+
27+
If none of those apply, stick with Studio Publish. Come back when you outgrow it.
28+
29+
## What deploy actually does
30+
31+
When you run `nevermore deploy run`:
32+
33+
1. Reads `deploy.nevermore.json` in your current directory and resolves the target you asked for (default: `test`).
34+
2. Runs `rojo build` on the target's `project` file to produce an `.rbxl` place file in a temp directory.
35+
3. Uploads the `.rbxl` to the configured `universeId` / `placeId` over Open Cloud.
36+
4. Saves the new version as a draft. If `--publish` is passed, it is also published as the live version.
37+
38+
That's the whole pipeline. There are no deploy hooks or post-processing steps to register.
39+
40+
## Prerequisites
41+
42+
- [Node.js](https://nodejs.org/) v18+ and the Nevermore CLI installed. The [Install guide](install.md) walks through both. The short version is `npm install -g @quenty/nevermore-cli`, or use `npx nevermore ...` from any package that depends on it.
43+
- [Rojo](https://rojo.space/docs/v7/getting-started/installation/) v7+ on your `PATH`.
44+
- A Roblox universe and place you own. You can create both at [create.roblox.com/dashboard/creations](https://create.roblox.com/dashboard/creations).
45+
- A Roblox Open Cloud API key. See [Logging in](#logging-in) below.
46+
47+
## Logging in
48+
49+
`nevermore deploy` authenticates against Open Cloud with an API key. Create one at [create.roblox.com/dashboard/credentials](https://create.roblox.com/dashboard/credentials) and grant it these scopes for the universe you want to deploy to:
50+
51+
| Scope | Used for |
52+
|-------|----------|
53+
| `universe-places:write` | Uploading new place versions |
54+
| `universe.place.luau-execution-session:write` | Running scripts (used by `nevermore test` and smoke tests) |
55+
| `universe.place.luau-execution-session:read` | Reading script execution results |
56+
| `legacy-asset:manage` | Downloading a [base place](testing/integration-testing.md#merging-with-an-existing-place-baseplace) (only needed if you use `basePlace`) |
57+
58+
Save the key to your machine once:
59+
60+
```bash
61+
nevermore login
62+
```
63+
64+
This stores the key at `~/.nevermore/credentials.json` (mode `0700`) after validating it against Open Cloud. Other useful flags:
65+
66+
- `nevermore login --force` swaps the stored key.
67+
- `nevermore login --clear` removes it.
68+
- `nevermore login --status` shows what's loaded and re-validates it.
69+
70+
### How the CLI finds your key
71+
72+
The CLI resolves credentials in this order (first match wins):
73+
74+
1. The `--api-key` CLI flag
75+
2. The `ROBLOX_OPEN_CLOUD_API_KEY` environment variable
76+
3. The `ROBLOX_UNIT_TEST_API_KEY` environment variable (kept for backwards compatibility)
77+
4. `~/.nevermore/credentials.json` (from `nevermore login`)
78+
79+
In CI, set `ROBLOX_OPEN_CLOUD_API_KEY` as a secret. `nevermore login` is for local developer machines.
80+
81+
## Setting up a package for deploy
82+
83+
`deploy.nevermore.json` is the only file the CLI needs to know about. The fastest way to create one is the interactive `init` wizard.
84+
85+
### `nevermore deploy init`
86+
87+
From inside the directory you want to deploy from (a package under `src/`, a game under `games/`, or any directory with a `package.json`):
88+
89+
```bash
90+
nevermore deploy init
91+
```
92+
93+
The wizard:
94+
95+
- Detects a `test/default.project.json` if one exists and offers it as the default Rojo project.
96+
- Detects `test/scripts/Server/ServerMain.server.lua` (or `.luau`) and offers it as the default script template.
97+
- Walks up the filesystem looking for a parent `deploy.nevermore.json` with a `universeId` and reuses it. Once you have one game configured, sibling packages can inherit the universe automatically.
98+
- Lists every existing place in the universe so you can pick one, or offers to create a new place.
99+
- Prints the resulting config and asks you to confirm before writing it.
100+
101+
#### Non-interactive setup
102+
103+
Pass `--yes` to skip prompts. You must supply enough flags for the wizard to resolve everything without asking:
104+
105+
```bash
106+
nevermore deploy init --yes \
107+
--universe-id 12345 \
108+
--place-id 67890 \
109+
--project default.project.json \
110+
--target test
111+
```
112+
113+
If you have the universe but no place yet, use `--create-place` to create one. Place creation is not exposed in Open Cloud, so this uses your `.ROBLOSECURITY` cookie instead. It only works on a machine that's logged in to Roblox.
114+
115+
```bash
116+
nevermore deploy init --yes \
117+
--universe-id 12345 \
118+
--create-place \
119+
--project default.project.json
120+
```
121+
122+
Other flags:
123+
124+
| Flag | Description |
125+
|------|-------------|
126+
| `--target <name>` | Name of the target to create (default: `test`) |
127+
| `--script-template <path>` | Set the Luau script template that `nevermore test` will run |
128+
| `--force` | Overwrite an existing `deploy.nevermore.json` |
129+
130+
### The `deploy.nevermore.json` schema
131+
132+
```json
133+
{
134+
"targets": {
135+
"test": {
136+
"universeId": 12345,
137+
"placeId": 67890,
138+
"project": "default.project.json",
139+
"scriptTemplate": "test/scripts/Server/ServerMain.server.lua",
140+
"basePlace": {
141+
"universeId": 12345,
142+
"placeId": 11111
143+
}
144+
}
145+
}
146+
}
147+
```
148+
149+
| Field | Required | Description |
150+
|-------|----------|-------------|
151+
| `targets` | yes | Map of target name to deploy config. Most packages start with a single `test` target. |
152+
| `targets.<name>.universeId` | yes | Roblox universe ID to deploy into. |
153+
| `targets.<name>.placeId` | yes | Roblox place ID. The build is uploaded here as a new version. |
154+
| `targets.<name>.project` | yes | Path to the Rojo project file, relative to the package directory. |
155+
| `targets.<name>.scriptTemplate` | no | Luau file `nevermore test` executes via Open Cloud after upload. Not used by `nevermore deploy` itself. |
156+
| `targets.<name>.basePlace` | no | Universe/place to download and merge with the rojo build before uploading. See [Merging with an existing place](testing/integration-testing.md#merging-with-an-existing-place-baseplace). |
157+
158+
You can declare any number of targets. A common setup is one `test` target for CI and a separate `production` or `staging` target for live deploys:
159+
160+
```json
161+
{
162+
"targets": {
163+
"test": { "universeId": 1, "placeId": 10, "project": "test/default.project.json" },
164+
"production": { "universeId": 1, "placeId": 20, "project": "default.project.json" }
165+
}
166+
}
167+
```
168+
169+
## Running a deploy
170+
171+
From the directory containing `deploy.nevermore.json`:
172+
173+
```bash
174+
# Build + upload to the default "test" target as a saved (draft) version
175+
nevermore deploy run
176+
177+
# Same, but publish so the new version is live for players
178+
nevermore deploy run --publish
179+
180+
# Deploy a specific target
181+
nevermore deploy run production --publish
182+
```
183+
184+
`nevermore deploy run` and the bare `nevermore deploy <target>` form are equivalent. `run` is the default subcommand.
185+
186+
On success you'll see one of:
187+
188+
```
189+
Saved v42 — not yet live.
190+
Published v42 — live in game.
191+
```
192+
193+
A "saved" version is uploaded but not visible to players. You can publish it later from the Roblox dashboard, or re-run with `--publish` to publish a fresh build. That version number matches what you'll see on the place page.
194+
195+
### Run flags
196+
197+
| Flag | Description |
198+
|------|-------------|
199+
| `--publish` | Publish the new version (default: save only) |
200+
| `--api-key <key>` | Open Cloud API key (overrides credential lookup) |
201+
| `--universe-id <id>` | Override the target's `universeId` |
202+
| `--place-id <id>` | Override the target's `placeId` |
203+
| `--place-file <path>` | Skip the rojo build and upload an existing `.rbxl` instead |
204+
| `--output <path>` | Write a JSON record of the deploy result to this path |
205+
206+
Global flags (available on every `nevermore` command):
207+
208+
| Flag | Description |
209+
|------|-------------|
210+
| `--yes` | Non-interactive (fails fast instead of prompting) |
211+
| `--dryrun` | Print what would happen without doing it |
212+
| `--verbose` | Verbose logging (rojo output, upload details) |
213+
214+
### Overriding the configured place
215+
216+
`--universe-id` and `--place-id` let you redirect a single deploy without editing the config. This is useful when you want to push the same build to a personal staging place for a one-off test:
217+
218+
```bash
219+
nevermore deploy run --universe-id 999 --place-id 8888
220+
```
221+
222+
### Uploading a pre-built place
223+
224+
If you already have a `.rbxl` (for example, one produced by `rojo build` in an upstream CI step), skip the rebuild:
225+
226+
```bash
227+
nevermore deploy run --place-file ./build/my-place.rbxl
228+
```
229+
230+
The `project` field in `deploy.nevermore.json` is ignored when `--place-file` is set, but `universeId` and `placeId` are still required.
231+
232+
## Batch deploys
233+
234+
If you want to deploy every game affected by a code change (for example, on every PR), use `nevermore batch deploy` instead. It scans the pnpm workspace for packages with a matching deploy target, uses `pnpm ls --filter` to figure out which ones changed since `origin/main`, and runs them in parallel.
235+
236+
See [Integration Testing → Batch deploy](testing/integration-testing.md#batch-deploy) for the full flag list and CI usage.
237+
238+
## Common workflows
239+
240+
### First-time setup for a new game
241+
242+
If you're starting from a clean directory, [`nevermore init`](install.md#fast-track-installing-via-npm-and-the-nevermore-cli-recommended) scaffolds a working Nevermore game template: a `default.project.json`, server/client entry scripts, and the default packages (`loader`, `servicebag`, `binder`, etc.). After that, `nevermore deploy init` only needs your universe and place IDs.
243+
244+
```bash
245+
mkdir my-game && cd my-game
246+
nevermore init # scaffold a Nevermore game template
247+
nevermore deploy init # configure the deploy target (interactive)
248+
nevermore deploy run # first upload, draft only
249+
nevermore deploy run --publish # publish when ready
250+
```
251+
252+
The wizard auto-detects the project file `nevermore init` creates, so you only answer prompts for universe and place. See [Install](install.md) for the full breakdown of what `nevermore init` produces, and [Integration Testing → Setting up a new integration game](testing/integration-testing.md#setting-up-a-new-integration-game) if you're building a game inside the Nevermore/Raven monorepo instead.
253+
254+
### Promoting a tested build to production
255+
256+
If you have separate `test` and `production` targets, the common pattern is to run the test target on every PR and only deploy production from `main`:
257+
258+
```bash
259+
# In CI on main
260+
nevermore deploy run production --publish
261+
```
262+
263+
There is no separate "promote" command. You deploy the same code to whichever target you want.
264+
265+
### Debugging a failed deploy
266+
267+
- Pass `--verbose` to see the rojo build output and the raw Open Cloud responses.
268+
- Pass `--dryrun` to confirm which target, universe, place, and project the CLI would use without uploading anything.
269+
- Check `nevermore login --status` if you suspect a credential problem.
270+
- Place uploads can fail with HTTP 403 when the API key is missing the `universe-places:write` scope for that specific universe. The scope is granted per-universe, not globally.
271+
272+
## See also
273+
274+
- [Intro](intro.md) — Why Nevermore, the major packages, and how the library is organized.
275+
- [Install](install.md) — Setting up Node, Rojo, the Nevermore CLI, and scaffolding a project with `nevermore init`.
276+
- [Integration Testing](testing/integration-testing.md)`basePlace` merging, smoke tests, batch deploys, CI integration.
277+
- [Test Infrastructure](testing/testing.md) — How `nevermore test` reuses the deploy config to run Jest specs in Open Cloud.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import * as fsPromises from 'fs/promises';
2+
import * as path from 'path';
3+
4+
async function pathExistsAsync(filePath: string): Promise<boolean> {
5+
try {
6+
await fsPromises.access(filePath);
7+
return true;
8+
} catch {
9+
return false;
10+
}
11+
}
12+
13+
/**
14+
* Walks up from `fromDirectory` looking for a `.git` entry and returns the
15+
* directory that contains it. Returns `undefined` if no git root is found.
16+
*/
17+
export async function findGitRepoRootAsync(
18+
fromDirectory: string
19+
): Promise<string | undefined> {
20+
let current = path.resolve(fromDirectory);
21+
while (true) {
22+
if (await pathExistsAsync(path.join(current, '.git'))) {
23+
return current;
24+
}
25+
const parent = path.dirname(current);
26+
if (parent === current) {
27+
return undefined;
28+
}
29+
current = parent;
30+
}
31+
}

tools/nevermore-cli-helpers/src/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export { VersionChecker } from './version-checker.js';
22

3+
export { findGitRepoRootAsync } from './find-git-repo-root.js';
4+
35
export {
46
getRobloxCookieAsync,
57
createPlaceInUniverseAsync,

tools/nevermore-cli/src/commands/deploy-command/deploy-init-utils.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,51 @@
1+
import * as fs from 'fs/promises';
12
import * as path from 'path';
23
import { fileExistsAsync } from '../../utils/nevermore-cli-utils.js';
34

5+
/**
6+
* Auto-detects the default deploy target name for a package.
7+
*
8+
* A package whose root `default.project.json` defines a full place
9+
* (`tree.$className === "DataModel"`) is treated as a game and defaults
10+
* to "integration". Otherwise (library — with or without a `test/` folder)
11+
* defaults to "test".
12+
*/
13+
export async function detectTargetNameAsync(
14+
packagePath: string
15+
): Promise<'test' | 'integration'> {
16+
if (await _isRootProjectDataModelAsync(packagePath)) {
17+
return 'integration';
18+
}
19+
return 'test';
20+
}
21+
22+
async function _isRootProjectDataModelAsync(
23+
packagePath: string
24+
): Promise<boolean> {
25+
const rootProject = path.join(packagePath, 'default.project.json');
26+
try {
27+
const content = await fs.readFile(rootProject, 'utf-8');
28+
const parsed = JSON.parse(content) as { tree?: { $className?: string } };
29+
return parsed.tree?.$className === 'DataModel';
30+
} catch {
31+
return false;
32+
}
33+
}
34+
435
export async function detectProjectFileAsync(
536
packagePath: string
637
): Promise<string | undefined> {
7-
const candidate = path.join(packagePath, 'test', 'default.project.json');
8-
if (await fileExistsAsync(candidate)) {
9-
return 'test/default.project.json';
38+
const candidates = [
39+
path.join(packagePath, 'test', 'default.project.json'),
40+
path.join(packagePath, 'default.project.json'),
41+
];
42+
43+
for (const candidate of candidates) {
44+
if (await fileExistsAsync(candidate)) {
45+
return path.relative(packagePath, candidate);
46+
}
1047
}
48+
1149
return undefined;
1250
}
1351

@@ -17,6 +55,8 @@ export async function detectScriptFileAsync(
1755
const candidates = [
1856
'test/scripts/Server/ServerMain.server.lua',
1957
'test/scripts/Server/ServerMain.server.luau',
58+
'scripts/Server/ServerMain.server.lua',
59+
'scripts/Client/ClientMain.server.luau',
2060
];
2161
for (const candidate of candidates) {
2262
if (await fileExistsAsync(path.join(packagePath, candidate))) {

0 commit comments

Comments
 (0)