Add plugin upload workflow and related enhancements

- Introduced a new upload workflow for the Atlas plugin to Roblox using Lune and OpenCloud.
- Added `UploadDetails.json` to store plugin metadata.
- Updated `build.rs` to include `UploadDetails.json` in the plugin snapshot.
- Enhanced `plugin.project.json` to reference the new `UploadDetails`.
- Added a new submodule for `opencloud-execute` to facilitate plugin uploads.
- Created scripts for publishing the plugin via both PowerShell and Bash.
- Updated GitHub Actions workflow to include steps for installing Rokit and uploading the plugin.

These changes aim to streamline the plugin release process and improve integration with Roblox's marketplace.
This commit is contained in:
Joe 2026-02-19 06:12:03 -06:00
parent 8cf140af3a
commit 4918fb80c7
16 changed files with 752 additions and 19 deletions

View file

@ -0,0 +1,161 @@
---
name: Merge plugin upload workflow
overview: Manually apply the upstream commit "Upload plugin as part of release workflow" to the Atlas fork, adapting all file references and metadata to match the fork's diverged codebase.
todos:
- id: new-files
content: Create .lune/.config.luau, .lune/scripts/plugin-upload.luau, .lune/upload-plugin.luau (adapted for atlas), plugin/UploadDetails.json (with Atlas values)
status: completed
- id: submodule
content: Add opencloud-execute submodule at .lune/opencloud-execute
status: completed
- id: build-rs
content: Add UploadDetails.json to plugin snapshot in build.rs
status: completed
- id: project-json
content: Add UploadDetails node to plugin.project.json
status: completed
- id: rokit
content: Add lune tool to rokit.toml
status: completed
- id: release-yml
content: Add Roblox upload step to release.yml build-plugin job
status: completed
- id: plugin-rs
content: Apply trivial test change in src/cli/plugin.rs
status: completed
- id: local-scripts
content: Replace scripts/publish-plugin.ps1 and add scripts/publish-plugin.sh to use lune-based upload
status: completed
isProject: false
---
# Merge Plugin Upload Release Workflow
Cherry-picking will produce conflicts in 6+ files due to heavy divergence. Manual application is the cleanest path.
## Upstream bugs to fix
**Swapped env vars in `release.yml`:** The upstream commit has `RBX_UNIVERSE_ID` mapped to `vars.PLUGIN_CI_PLACE_ID` and `RBX_PLACE_ID` mapped to `vars.PLUGIN_CI_UNIVERSE_ID` -- clearly crossed. We will use the correct mapping.
## Local scripts replacement
`scripts/publish-plugin.ps1` currently uses `cargo run -- upload`. Replace it (and add missing `.sh`) to use the same Lune-based upload pipeline locally. The `.lune/upload-plugin.luau` script already handles the local case (builds the plugin via `atlas build` when `CI != "true"`), so the wrapper scripts just load `.env` and run `lune run upload-plugin Atlas.rbxm`.
`.env` uses the same names as GitHub Actions secrets/vars:
```
PLUGIN_UPLOAD_TOKEN=your-opencloud-api-key
PLUGIN_CI_UNIVERSE_ID=your-universe-id
PLUGIN_CI_PLACE_ID=your-place-id
```
The local scripts map these to `RBX_API_KEY`/`RBX_UNIVERSE_ID`/`RBX_PLACE_ID` before calling lune, matching what the CI workflow `env:` block does.
## What the commit adds
Automated plugin upload to Roblox as part of the GitHub release workflow, using [Lune](https://lune-org.github.io/docs) + OpenCloud Luau Execute to publish the built `.rbxm` to Roblox's plugin marketplace.
## New files (create as-is, with Atlas adaptations)
### `.lune/.config.luau`
Lune config with strict mode and typedefs alias. Copy verbatim from upstream.
### `.lune/scripts/plugin-upload.luau`
Roblox engine script that deserializes the plugin `.rbxm`, reads `UploadDetails`, and calls `AssetService:CreateAssetVersionAsync`. Copy verbatim -- it's generic (reads values from UploadDetails at runtime).
### `.lune/upload-plugin.luau`
Lune entry point that reads the `.rbxm`, sends it to OpenCloud for execution. One adaptation needed: change `process.exec("rojo", ...)` to `process.exec("atlas", ...)` on the local-testing path (line 25 in upstream).
### `plugin/UploadDetails.json`
Atlas-specific values per user input:
```json
{
"assetId": 111151139098227,
"name": "Atlas",
"description": "The plugin portion of Atlas, a tool to enable professional tooling for Roblox developers.",
"creatorId": 422368266,
"creatorType": "Group"
}
```
## Submodule addition
### `.lune/opencloud-execute`
Add git submodule: `https://github.com/Dekkonot/opencloud-luau-execute-lune.git` at `.lune/opencloud-execute`. This also adds an entry to `[.gitmodules](.gitmodules)`.
## Existing file modifications
### `[.github/workflows/release.yml](.github/workflows/release.yml)`
Two additions to the `build-plugin` job:
1. **Add Rokit install step** (after "Install Rust", before "Build Atlas") so `lune` is on PATH:
```yaml
- name: Install Rokit
uses: CompeyDev/setup-rokit@v0.1.2
```
1. **Add upload step** (after "Upload Plugin to Artifacts" at line 47) with **corrected** env var mapping (upstream had these swapped):
```yaml
- name: Upload Plugin to Roblox
env:
RBX_API_KEY: ${{ secrets.PLUGIN_UPLOAD_TOKEN }}
RBX_UNIVERSE_ID: ${{ vars.PLUGIN_CI_UNIVERSE_ID }}
RBX_PLACE_ID: ${{ vars.PLUGIN_CI_PLACE_ID }}
run: lune run upload-plugin Atlas.rbxm
```
### `[build.rs](build.rs)`
Add `"UploadDetails.json"` entry to the plugin snapshot hashmap, after `"Version.txt"` on line 98:
```rust
"UploadDetails.json" => snapshot_from_fs_path(&plugin_dir.join("UploadDetails.json"))?,
```
### `[plugin.project.json](plugin.project.json)`
Add `UploadDetails` child node after `Version` (line 25):
```json
"UploadDetails": {
"$path": "plugin/UploadDetails.json"
}
```
### `[rokit.toml](rokit.toml)`
Add lune tool (latest version) after the existing entries:
```toml
lune = "lune-org/lune@0.10.4"
```
### `[src/cli/plugin.rs](src/cli/plugin.rs)`
Trivial test change on line 101: `assert!(initialize_plugin().is_ok())` becomes `let _ = initialize_plugin().unwrap();`
### `[scripts/publish-plugin.ps1](scripts/publish-plugin.ps1)`
Replace entirely. New version: load `.env`, export `RBX_API_KEY`/`RBX_UNIVERSE_ID`/`RBX_PLACE_ID`, run `lune run upload-plugin Atlas.rbxm`.
### `scripts/publish-plugin.sh` (new)
Bash equivalent: source `.env`, export vars, run `lune run upload-plugin Atlas.rbxm`.
## Required GitHub secrets/vars
After merging, the repo needs these configured in GitHub settings:
- **Secret:** `PLUGIN_UPLOAD_TOKEN` -- Roblox OpenCloud API key
- **Variable:** `PLUGIN_CI_UNIVERSE_ID` -- Universe ID for the place used by OpenCloud Execute
- **Variable:** `PLUGIN_CI_PLACE_ID` -- Place ID within that universe

View file

@ -0,0 +1,390 @@
---
name: Rokit Install Audit Fixes
overview: Fix the EPERM crash when deleting a globally installed atlas.exe, and harden the entire rokit installation pipeline in the VS Code extension for reliability across Windows, macOS, and Linux.
todos:
- id: bug1-elevated-delete
content: Add elevatedDelete() helper and fix the 'Switch to Rokit' flow in openMenu.ts to handle EPERM/EACCES/EBUSY with platform-specific elevation
status: completed
- id: bug2-progress
content: Wrap installRojo() in vscode.window.withProgress() with stage reporting
status: completed
- id: bug3-github-api
content: Replace eryn.io proxy URL with official GitHub API URL in installRojo.ts
status: completed
- id: bug4-temp-cleanup
content: Delete temp rokit binary after successful self-install
status: completed
- id: bug5-path-dedup
content: Deduplicate PATH entry and verify rokit accessibility before rokit add
status: completed
- id: bug6-stream
content: Replace manual stream piping with stream.promises.pipeline()
status: completed
- id: bug7-rokit-add-existing
content: Use 'rokit add --global --force' + 'rokit trust' + verify atlas --version + non-fatal plugin install
status: completed
- id: feat-check-updates
content: Add 'Check for Updates' menu item in openMenu.ts for rokit-managed installs, handle project-level + global rokit update, reinstall Studio plugin after update
status: completed
- id: feat-auto-update-check
content: Add background update check on activation using rokit update --check (once per day, project + global), prompt user with Update button that triggers rokit update + plugin reinstall
status: completed
isProject: false
---
# Rokit Installation Audit and Fixes
## Audit Findings
### How Rokit Installation Works (No Elevation Needed)
Rokit installs entirely to `~/.rokit/` (user-level directory). `rokit self-install` modifies user-level PATH environment variables (HKCU on Windows, shell rc files on Unix). **Neither rokit installation nor `rokit add` requires elevation.**
The only scenario requiring elevation is the "Switch to Rokit" migration flow, which tries to delete an existing globally installed `atlas.exe` from a protected directory like `C:\Program Files\Atlas` (written by [install.ps1](install.ps1)) or `/usr/local/bin/atlas` (written by [install.sh](install.sh)).
### Issues Found (by severity)
---
**BUG 1 -- CRITICAL: `fs.unlink` fails without elevation, crashes the entire flow**
In [vscode-rojo/src/commands/openMenu.ts](vscode-rojo/src/commands/openMenu.ts) line 133:
```typescript
return fs.unlink(install.resolvedPath)
```
When atlas.exe is in `C:\Program Files\Atlas` or `/usr/local/bin/`, this throws EPERM/EACCES. The `.then()` error handler on line 139 catches it but just shows a generic message and the flow dies. Additionally, on Windows the exe may be **locked by a running process** (atlas serve), causing EBUSY even with elevation.
**Fix:** Add an `elevatedDelete()` helper that:
1. Tries `fs.unlink()` first
2. On EPERM/EACCES, uses platform-specific elevated delete:
- **Windows:** `powershell -Command "Start-Process powershell -ArgumentList '-Command Remove-Item \"path\"' -Verb RunAs -Wait"` (triggers UAC prompt)
- **macOS:** `osascript -e 'do shell script "rm path" with administrator privileges'` (triggers password dialog)
- **Linux:** `pkexec rm path` (triggers PolicyKit dialog)
3. On EBUSY (Windows exe locked), shows a specific error message telling the user to stop atlas serve first
---
**BUG 2 -- HIGH: No progress indicator during installation**
In [vscode-rojo/src/installRojo.ts](vscode-rojo/src/installRojo.ts) `installRojo()`:
The entire flow (download rokit zip ~3.5MB, extract, self-install, `rokit add` atlas) runs silently. Users see zero feedback and may think the extension froze.
**Fix:** Wrap the body of `installRojo()` in `vscode.window.withProgress()` with `ProgressLocation.Notification` and `cancellable: false`. Report progress at each stage:
- "Downloading Rokit..."
- "Installing Rokit..."
- "Installing Atlas via Rokit..."
---
**BUG 3 -- HIGH: Third-party release URL is a single point of failure**
In [vscode-rojo/src/installRojo.ts](vscode-rojo/src/installRojo.ts) line 113:
```typescript
const latestReleaseResponse = await fetch(
"https://latest-github-release.eryn.io/rojo-rbx/rokit",
)
```
This third-party proxy (`eryn.io`) is not under our control. If it goes down, installation breaks. The official rokit install scripts use the GitHub API directly.
**Fix:** Replace with the official GitHub API URL: `https://api.github.com/repos/rojo-rbx/rokit/releases/latest`. Verified: response schema is identical, both return the same `GitHubRelease` shape. Add an `Accept: application/vnd.github+json` header per GitHub API best practice.
---
**BUG 4 -- MEDIUM: Temp files never cleaned up**
In [vscode-rojo/src/installRojo.ts](vscode-rojo/src/installRojo.ts) line 143-170:
The rokit binary is downloaded to `os.tmpdir()` but never deleted after `rokit self-install` succeeds.
**Fix:** Add `fs.promises.unlink(tempPath).catch(() => {})` after the `exec(self-install)` call succeeds.
---
**BUG 5 -- MEDIUM: PATH update is fragile**
In [vscode-rojo/src/installRojo.ts](vscode-rojo/src/installRojo.ts) lines 177-181:
```typescript
if ("PATH" in process.env) {
const envPath = process.env.PATH!.split(path.delimiter)
envPath.push(path.join(os.homedir(), ".rokit", "bin"))
process.env.PATH = envPath.join(path.delimiter)
}
```
This only updates the extension's own process environment. But the subsequent `rokit add --global` on line 184 runs via `exec()` which inherits this env, so it should work. The concern is:
- It doesn't check if the path is already present (could add duplicates on repeated attempts)
- The rokit binary path might differ on some systems
**Fix:** Check for duplicates before appending. Also verify the rokit binary is actually accessible after the PATH update by doing a quick `which("rokit")` check before proceeding to `rokit add`.
---
**BUG 6 -- LOW: Stream error handling is fragile**
In [vscode-rojo/src/installRojo.ts](vscode-rojo/src/installRojo.ts) lines 51-60, 149-163:
`promisifyStream` resolves on whichever of "close"/"finish"/"end" fires first. The pipeline/pipe chain has multiple potential failure modes that aren't properly propagated.
**Fix:** Use `stream.promises.pipeline()` (available in Node 15+, which VS Code ships with) instead of manually piping and tracking events. This gives proper error propagation and backpressure handling. Alternatively, simplify to `await promisify(stream.pipeline)(download.body!, unzipper.ParseOne(), fs.createWriteStream(tempPath))`.
---
**BUG 7 -- HIGH: `rokit add --global` crashes if atlas is already configured**
In [vscode-rojo/src/installRojo.ts](vscode-rojo/src/installRojo.ts) line 184:
```typescript
await exec("rokit add --global UserGeneratedLLC/rojo atlas")
```
This is the **only** action after rokit is confirmed installed. It fails if atlas is already in the project's `rokit.toml` or already added globally. The scenario:
1. Project has `rokit.toml` with atlas listed but `rokit install` hasn't been run yet
2. `which("atlas")` finds the rokit shim at `~/.rokit/bin/atlas`
3. `atlas --version` fails (rokit says tool not installed), stderr contains "rokit" -> `getRojoInstall` returns null
4. User sees "Install Atlas now", clicks it
5. `isRokitInstalled()` returns true (skips rokit download)
6. `rokit add --global` fails because atlas is already configured -> **onboarding dies**
**Fix:** Use `--force` to handle "already exists", trust the tool, then verify and install the plugin:
```typescript
await exec("rokit add --global --force UserGeneratedLLC/rojo atlas")
await exec("rokit trust UserGeneratedLLC/rojo")
await exec("atlas --version")
// Install the Studio plugin (non-fatal -- don't crash onboarding if Studio isn't installed)
try {
await exec("atlas plugin install")
} catch (e: any) {
vscode.window.showWarningMessage(
`Atlas installed successfully, but the Studio plugin could not be installed: ${e.stderr || e}. ` +
`You can install it later from the Atlas menu or by running "atlas plugin install".`
)
}
```
- `--force` on `rokit add` handles the "already exists" case cleanly -- no try/catch needed, it re-adds/updates regardless
- `rokit trust` marks `UserGeneratedLLC/rojo` as trusted so rokit won't prompt the user for confirmation when running atlas (without trust, `rokit install` and first-run shims can block waiting for user input)
- `atlas --version` verifies the tool is actually usable after add
- `atlas plugin install` writes the embedded Studio plugin to disk (non-fatal if Studio isn't installed yet)
---
---
**FEATURE: "Check for Updates" menu item for Rokit-managed installs**
In [vscode-rojo/src/commands/openMenu.ts](vscode-rojo/src/commands/openMenu.ts), the `generateProjectMenu()` return array (line 315) has action items like Studio, Syncback, Sourcemap, Install Plugin. There is no way to update Atlas from the extension.
`generateProjectMenu()` already tracks `installType` (line 168) and whether it's `InstallType.rokit`. We use this to conditionally show the item.
**Implementation:**
1. Add a menu item in `generateProjectMenu()` return array, conditionally included when `installType === InstallType.rokit`:
```typescript
...(installType === InstallType.rokit
? [
{
label: "$(sync) Check for Updates",
description: `Atlas v${allRojoVersions[0]}`,
action: "checkUpdates",
projectFile: projectFiles[0],
},
]
: []),
```
1. Add `"checkUpdates"` case in the `onDidAccept` handler (after the `"install"` case). The handler needs to support both project-level `rokit.toml` and global installations:
```typescript
case "checkUpdates": {
if (!selectedItem.projectFile) return
const updateFolder = path.dirname(selectedItem.projectFile.path.fsPath)
input.hide()
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "Checking for Atlas updates..." },
async (progress) => {
// Try project-level update first, fall back to global
let updateOutput: string
try {
const result = await exec("rokit update atlas", { cwd: updateFolder })
updateOutput = result.stdout || result.stderr
} catch {
try {
const result = await exec("rokit update --global atlas")
updateOutput = result.stdout || result.stderr
} catch (e: any) {
vscode.window.showErrorMessage(`Could not update Atlas: ${e.stderr || e}`)
return
}
}
// Re-install the Studio plugin so it matches the new binary.
// The plugin is embedded in the atlas binary at compile time
// (include_bytes! in src/cli/plugin.rs), so updating the binary
// without re-installing leaves a stale AtlasManagedPlugin.rbxm
// in Studio's plugins folder. Mismatched versions break live sync
// due to protocol version enforcement.
progress.report({ message: "Reinstalling Studio plugin..." })
try {
await exec("atlas plugin install")
} catch (e: any) {
vscode.window.showWarningMessage(
`Atlas updated, but plugin reinstall failed: ${e.stderr || e}. ` +
`Run "atlas plugin install" manually to avoid version mismatch.`
)
}
vscode.window.showInformationMessage(updateOutput.trim() || "Atlas is up to date.")
},
)
break
}
```
The strategy: try `rokit update atlas` in the project directory first. If atlas isn't in the project's `rokit.toml`, this fails and we fall back to `rokit update --global atlas`. Rokit's output tells the user whether an update was applied or they're already current.
After a successful update, immediately runs `atlas plugin install` to overwrite the stale `AtlasManagedPlugin.rbxm` in Studio's plugins folder. The plugin is baked into the atlas binary via `include_bytes!` at compile time (`src/cli/plugin.rs:12`), so the `.rbxm` in Studio never auto-updates -- it must be explicitly reinstalled. If the plugin reinstall fails (e.g. Studio not installed), a warning is shown with manual instructions rather than failing the whole update.
---
**FEATURE: Background update check on extension activation**
In [vscode-rojo/src/extension.ts](vscode-rojo/src/extension.ts) `activate()`:
Rokit has `rokit update atlas --check` which checks for updates without installing. Supports both project-level (`rokit update atlas --check` in the project dir, reads `rokit.toml`) and global (`rokit update --global atlas --check`).
**Implementation:** Add a new file `vscode-rojo/src/checkForUpdates.ts` with a `checkForAtlasUpdates(context)` function, called fire-and-forget from `activate()`:
```typescript
export async function checkForAtlasUpdates(context: vscode.ExtensionContext) {
// Throttle: once per day
const lastCheck = context.globalState.get<number>("atlas::lastUpdateCheck")
const ONE_DAY = 24 * 60 * 60 * 1000
if (lastCheck && Date.now() - lastCheck < ONE_DAY) return
// Check if rokit is available
const rokitPath = await which("rokit").catch(() => null)
if (!rokitPath) return
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
// rokit update --check prints update info if available, or "up to date" if current.
// Check both project-level and global.
let checkOutput: string | null = null
let updateScope: "project" | "global" = "global"
// Try project-level first (if workspace is open)
if (workspaceFolder) {
try {
const result = await exec("rokit update atlas --check", { cwd: workspaceFolder })
const output = (result.stdout + result.stderr).trim()
if (output && !output.toLowerCase().includes("up to date")) {
checkOutput = output
updateScope = "project"
}
} catch {
// atlas not in project rokit.toml -- fall through
}
}
// Try global if no project-level update found
if (!checkOutput) {
try {
const result = await exec("rokit update --global atlas --check")
const output = (result.stdout + result.stderr).trim()
if (output && !output.toLowerCase().includes("up to date")) {
checkOutput = output
updateScope = "global"
}
} catch {
// atlas not installed globally via rokit
}
}
context.globalState.update("atlas::lastUpdateCheck", Date.now())
if (!checkOutput) return
const choice = await vscode.window.showInformationMessage(
`An Atlas update is available. ${checkOutput}`,
"Update",
"Dismiss",
)
if (choice !== "Update") return
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "Updating Atlas..." },
async (progress) => {
try {
if (updateScope === "project" && workspaceFolder) {
await exec("rokit update atlas", { cwd: workspaceFolder })
} else {
await exec("rokit update --global atlas")
}
} catch (e: any) {
vscode.window.showErrorMessage(`Could not update Atlas: ${e.stderr || e}`)
return
}
progress.report({ message: "Reinstalling Studio plugin..." })
try {
await exec("atlas plugin install")
} catch {
vscode.window.showWarningMessage(
"Atlas updated, but Studio plugin reinstall failed. Run 'atlas plugin install' manually.",
)
}
vscode.window.showInformationMessage("Atlas updated successfully.")
},
)
}
```
Called from `activate()` as fire-and-forget (no `await`) so it never blocks extension startup:
```typescript
// In activate(), after all synchronous setup is done:
checkForAtlasUpdates(context).catch(() => {})
```
Key design decisions:
- **Uses `rokit update --check`**: Lets rokit handle version resolution natively -- no GitHub API calls, no manual semver parsing
- **Project-level first, then global**: Checks the project's `rokit.toml` first (if workspace is open), falls back to global. When updating, uses the same scope that found the update
- **Throttle**: Once per day via `globalState`, so frequent restarts don't spam the user
- **Silent failures**: Every step is wrapped in try/catch that returns early. Missing rokit, no atlas configured, network errors -- all silently ignored
- **Non-blocking**: Called without `await` so extension activation is never delayed
---
### Summary of What's Working Correctly
- Asset name regex (`-(?<platform>\w+)-(?<arch>\w+)\.zip$`) correctly matches all 6 rokit 1.2.0 release assets (linux/macos/windows x x86_64/aarch64, all `.zip`)
- Platform/arch mapping covers all common Node.js platforms (win32, darwin, linux) and architectures (x64, arm64)
- `rokit self-install` correctly sets up `~/.rokit/bin` in user PATH on all platforms
- `chmod 0o755` is correctly applied on Unix after extraction
- `getRojoInstall` correctly detects rokit-managed vs globally installed atlas
## Files to Change
- [vscode-rojo/src/installRojo.ts](vscode-rojo/src/installRojo.ts) -- Bugs 2, 3, 4, 5, 6, 7
- [vscode-rojo/src/commands/openMenu.ts](vscode-rojo/src/commands/openMenu.ts) -- Bug 1, Check for Updates menu item
- [vscode-rojo/src/checkForUpdates.ts](vscode-rojo/src/checkForUpdates.ts) -- NEW: background update check logic
- [vscode-rojo/src/extension.ts](vscode-rojo/src/extension.ts) -- Call checkForAtlasUpdates() on activation