Skip to content

Commit 51514c9

Browse files
authored
feat(studio-bridge): persistent sessions and Linux/Wine support (#669)
* chore(ci): add TypeScript lint/test scripts and dedicated typescript workflow * chore(format): apply prettier to pre-existing TypeScript files for CI baseline * chore: update pnpm lockfile and devcontainer for studio-bridge work * feat(cli-output-helpers): add Reporter framework primitives and ResultReporter for single-result output * refactor(auth): consolidate auth into nevermore-cli-helpers with cookie/ and open-cloud/ split * feat(studio-bridge): add v2 protocol, transport, and persistent bridge host * feat(studio-bridge): rewrite plugin template with action handlers, screenshot capture, PNG encoder * feat(studio-bridge): add CLI commands (sessions, exec, run, query, screenshot, logs, plugin) with declarative grouping * feat(studio-bridge): add persistent plugin manager * refactor(template-helpers): add Linux fallback for rojo --plugin and prefix verbose log lines * feat(studio-bridge): add Linux/Wine support and Docker image for headless E2E * refactor(studio-bridge): drop v1 protocol path * fix(studio-bridge): address review feedback (security, bugs, cleanup) Security - Use execFileSync with args arrays in FileResultReporter and linux-credential-writer instead of execSync + JSON.stringify quoting, which is not equivalent to shell quoting. - Pass ROBLOSECURITY through the docker process env (via -e ROBLOSECURITY with no value) instead of baking it into argv, where it would be visible to ps on the host. - Tighten ~/.nevermore/credentials.json to mode 0600 (and parent dir 0700). - Use err.message instead of template-stringifying raw err in validateApiKeyAsync. - Restore the terminal cursor in watch-renderer on render-callback errors and SIGINT/SIGTERM, instead of leaving the cursor hidden. Bugs - process run no longer drops the global --verbose flag; it reads the effective value via OutputHelper.isVerbose(). - exec/run resolve script file paths to absolute via path.resolve and switch from sync fs.readFileSync to async fs.readFile. - explorer query honors --depth even when --descendants is not passed. - process close (still a stub) returns success: false so the adapter exits non-zero. - StudioBridgePlugin sends state "Edit" on register (was "ready", which is not a valid StudioState) and advertises the dynamically-dispatched capabilities (execute, queryState, captureScreenshot, queryDataModel, queryLogs). - Persistent plugin install is now atomic: rojo builds into the temp build dir and the result is copyFile + rename'd into the plugins folder so Studio's polling watcher never observes a partial .rbxm. - Plugin uninstall uses unlink-with-ENOENT-handling instead of a pre-check, removing a TOCTOU window. - CompositeResultReporter teardown is failure-isolated via Promise.allSettled so a throwing reporter no longer skips siblings. - DiscoveryStateMachine drops the dead _currentPort rotation logic (the field was set on construction but never advanced, so the rotation was a no-op anyway given parallel scanPortsAsync). - png.luau asserts that dynamic-Huffman code 16 (repeat-previous) is not the first symbol; previously a malformed input would silently no-op into table.insert(nil) and spin the until-loop forever. Cleanup - Remove stale welcome / protocolVersion: 2 references left over from the v1 protocol drop in plugin-test templates and hand-off.test.ts; drop the matching "(v2)" suffix in describe and rename web-socket-protocol-v2.test.ts to web-socket-protocol.test.ts (the prior basic file becomes web-socket-protocol-basic.test.ts). - Rename linux-display-manager sleep to sleepAsync per repo convention. - Extract resolveScriptContentAsync (shared by exec and run) and colorizeState (shared by process list and process info). - Combine duplicate getPersistentPluginPath imports in uninstall.ts. CI - Guard the new TS jobs' .npmrc _authToken= writes on $NPM_TOKEN / $GITHUB_TOKEN being non-empty, so fork PRs don't append empty _authToken= lines. - Gate the studio-linux-ci "Diagnose Wine networking" step behind failure() instead of always() so it doesn't run on every successful PR. * fix(studio-bridge): correlate plugin responses via PendingRequestMap sendToPluginAsync attached a fresh ws.on('message', ...) listener per call and matched the first non-heartbeat reply (or any error) when no requestId was supplied. With ≥2 requests in flight this could (a) cross responses between callers, (b) let an unrelated error reply satisfy a real request, and (c) leak listeners until the EventEmitter MaxListeners warning fired. Replace the per-call pattern with a per-connection PluginConnectionState that owns a PendingRequestMap. A single dispatcher is installed in _registerPlugin; it routes plugin responses by requestId. Every outgoing request must carry a requestId — all real callers (BridgeSession, BridgeClient) already do; ensureRequestId is a defensive fallback. Pending requests are cancelled on plugin disconnect, replacement, host stopAsync, and shutdownAsync (graceful + force-close paths). New unit tests in bridge-host.test.ts cover: - concurrent in-flight requests resolve to the correct caller even when the plugin replies in reverse order - an error reply with an unrelated requestId no longer satisfies a pending request (it now times out, as it should) - pending requests reject when the plugin disconnects - the dispatcher is installed once and does not accumulate listeners across many requests All 669 TS tests + 158 plugin Lune tests pass. * feat(studio-bridge): validate HostEnvelope action via zod schemas Replace the unchecked `obj.action as ServerMessage` cast in decodeHostMessage with a zod discriminated union covering all nine ServerMessage variants. A buggy or hostile /client connection can no longer forward malformed actions to the plugin. Adds nine targeted tests for missing/unknown action types, wrong field types, and unknown error codes. * test(studio-bridge): cover waitForSessionsToSettleAsync settle path The settle path that gates cold-start commands had no direct test coverage — all bridge-connection tests used keepAlive: true, which short-circuits the settle in the constructor. Adds five targeted tests using the existing options parameter to inject small timeouts: no-plugin firstSessionTimeout exit, single-plugin settleMs quiet wait, settle timer reset on second plugin, maxMs cap on continuous streams, and immediate return for client role. Establishes a baseline before any settle-constant tuning. * chore(studio-bridge): drop unused CommandRegistry.discoverAsync cli.ts has always registered commands explicitly, and discoverAsync (plus DiscoverOptions and the _tryImportAsync helper) was never wired into production. Removes the convention-based loader, its barrel re-export, and its seven unit tests. Net −245 / +4 lines. * refactor(studio-bridge,cli-output-helpers): simplify single-result CLI output The single-result output path had grown a 3-mode dispatch system (text | table | json) plus a studio-bridge-specific base64 extension, all routed through a thin format-output.ts wrapper that re-exported upstream types. On inspection: every command's text and table formatters were literally identical fns — text/table was a phantom distinction nobody used; base64 was a stdout pipeline strictly inferior to --output file.png. Collapses the per-command `formatResult: { text, table, json }` dict to two optional callbacks: cli.format(result) — human terminal output (also for --format=text) cli.json(result) — overrides default formatJson, e.g. drop binary Hoists reporter selection (Stdout/File/Watch dispatch) into a new `buildResultReporter` factory in cli-output-helpers — the package that owns the reporter classes — and inlines its construction in the adapter handler, which is now mode-blind. Drops --format=base64 (binary file output via -o is unaffected; binaryField + extractBinaryBuffer remain), deletes the format-output.ts wrapper and its tests, and removes the unused upstream output-mode module (OutputMode/resolveOutputMode no longer have any callers). Updates tools/CLAUDE.md to match. Net: −240 lines across the four formerly-separate refactor steps. * chore(studio-bridge): emit apt manifest as Docker build artifact Apt deps (winehq-stable, nodejs, gh, ...) in the studio-linux Docker image aren't pinned to specific versions. That's a deliberate trade-off versus the brittleness of "this exact apt version is no longer in the repo," but it leaves drift across rebuilds invisible. Dumps a sorted TSV of all installed packages and versions to /image-manifest-apt.tsv at build time, then has the studio-linux-ci workflow extract and upload it as a 90-day artifact. When a future rebuild mysteriously breaks Studio, diffing the manifest against the last known-good build shows what actually changed. * refactor(studio-bridge): use EncodingService:Base64Encode for screenshot Replaces the hand-rolled buffer-based base64 encoder (~60 lines, including a 64-entry ASCII LUT and a localized hot loop) with a call to Roblox's built-in EncodingService:Base64Encode. The native implementation is faster than any Luau loop and removes a chunk of vendored encoding logic we'd otherwise have to maintain. Resolves a code review note from Quenty. * chore(vscode): enable file nesting for init.lua and sibling files * docs(studio-bridge): trim programmatic API and protocol sections from README * chore(ci): skip studio-linux-ci e2e when ROBLOSECURITY cookie is stale
1 parent 754fea6 commit 51514c9

258 files changed

Lines changed: 34306 additions & 2357 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.devcontainer/devcontainer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"version": "22",
1111
"pnpmVersion": "10.27.0"
1212
},
13-
"ghcr.io/devcontainers/features/github-cli:1": {}
13+
"ghcr.io/devcontainers/features/github-cli:1": {},
14+
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
1415
},
1516

1617
"customizations": {
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
name: studio-linux-ci
2+
3+
on:
4+
schedule:
5+
- cron: '0 3 * * *' # Nightly 03:00 UTC
6+
workflow_dispatch:
7+
inputs:
8+
studio_version:
9+
description: 'Override Studio version hash (leave empty for latest)'
10+
required: false
11+
push:
12+
branches: [main]
13+
paths:
14+
- 'tools/studio-bridge/docker/**'
15+
- 'tools/studio-bridge/src/**'
16+
- '.github/workflows/studio-linux-ci.yml'
17+
pull_request:
18+
paths:
19+
- 'tools/studio-bridge/docker/**'
20+
- 'tools/studio-bridge/src/**'
21+
- 'tools/nevermore-cli-helpers/src/auth/**'
22+
- '.github/workflows/studio-linux-ci.yml'
23+
24+
concurrency:
25+
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
26+
cancel-in-progress: true
27+
28+
env:
29+
REGISTRY: ghcr.io
30+
IMAGE_NAME: quenty/nevermore-studio-linux
31+
32+
jobs:
33+
build:
34+
runs-on: ubuntu-latest
35+
permissions:
36+
contents: read
37+
packages: write
38+
outputs:
39+
tag: ${{ steps.tag.outputs.tag }}
40+
steps:
41+
- name: Resolve Studio version
42+
id: resolve
43+
run: |
44+
if [ -n "${{ inputs.studio_version }}" ]; then
45+
VERSION="${{ inputs.studio_version }}"
46+
else
47+
VERSION=$(curl -s https://clientsettingscdn.roblox.com/v2/client-version/WindowsStudio64 | jq -r .clientVersionUpload)
48+
fi
49+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
50+
echo "short=${VERSION:0:12}" >> "$GITHUB_OUTPUT"
51+
echo "Resolved Studio version: $VERSION"
52+
53+
# Canary builds for PRs and non-main branches
54+
BRANCH="${{ github.head_ref || github.ref_name }}"
55+
if [ "$BRANCH" != "main" ]; then
56+
BRANCH_SLUG="${BRANCH//\//-}"
57+
SHORT_SHA="${GITHUB_SHA:0:8}"
58+
echo "is_canary=true" >> "$GITHUB_OUTPUT"
59+
echo "canary_tag=canary-${BRANCH_SLUG}-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
60+
echo "branch_tag=canary-${BRANCH_SLUG}" >> "$GITHUB_OUTPUT"
61+
echo "Canary build: canary-${BRANCH_SLUG}-${SHORT_SHA}"
62+
else
63+
echo "is_canary=false" >> "$GITHUB_OUTPUT"
64+
echo "canary_tag=" >> "$GITHUB_OUTPUT"
65+
fi
66+
67+
- name: Compute image tag for downstream jobs
68+
id: tag
69+
run: |
70+
if [ "${{ steps.resolve.outputs.is_canary }}" = "true" ]; then
71+
echo "tag=${{ steps.resolve.outputs.branch_tag }}" >> "$GITHUB_OUTPUT"
72+
else
73+
echo "tag=latest" >> "$GITHUB_OUTPUT"
74+
fi
75+
76+
- name: Check if image already exists
77+
id: check
78+
run: |
79+
# Always rebuild canary images
80+
if [ "${{ steps.resolve.outputs.is_canary }}" = "true" ]; then
81+
echo "exists=false" >> "$GITHUB_OUTPUT"
82+
echo "Canary build — always rebuild"
83+
exit 0
84+
fi
85+
86+
if docker manifest inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.version }} > /dev/null 2>&1; then
87+
echo "exists=true" >> "$GITHUB_OUTPUT"
88+
echo "Image already exists for version ${{ steps.resolve.outputs.version }}, skipping build"
89+
else
90+
echo "exists=false" >> "$GITHUB_OUTPUT"
91+
echo "Image not found, will build"
92+
fi
93+
env:
94+
DOCKER_CLI_EXPERIMENTAL: enabled
95+
96+
- name: Checkout repository
97+
if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
98+
uses: actions/checkout@v6
99+
100+
- name: Set up Docker Buildx
101+
if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
102+
uses: docker/setup-buildx-action@v3
103+
104+
- name: Log in to GitHub Container Registry
105+
if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
106+
uses: docker/login-action@v3
107+
with:
108+
registry: ${{ env.REGISTRY }}
109+
username: ${{ github.actor }}
110+
password: ${{ secrets.GITHUB_TOKEN }}
111+
112+
- name: Compute image tags
113+
if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
114+
id: tags
115+
run: |
116+
if [ "${{ steps.resolve.outputs.is_canary }}" = "true" ]; then
117+
# Tag with both the SHA-specific and stable branch tags
118+
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.canary_tag }}
119+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.branch_tag }}"
120+
else
121+
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
122+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.version }}
123+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.short }}"
124+
fi
125+
echo "tags<<ENDOFTAGS" >> "$GITHUB_OUTPUT"
126+
echo "$TAGS" >> "$GITHUB_OUTPUT"
127+
echo "ENDOFTAGS" >> "$GITHUB_OUTPUT"
128+
129+
- name: Build and push image
130+
if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
131+
uses: docker/build-push-action@v6
132+
with:
133+
context: tools/studio-bridge/docker
134+
build-contexts: workspace=.
135+
build-args: |
136+
STUDIO_VERSION=${{ steps.resolve.outputs.version }}
137+
push: true
138+
tags: ${{ steps.tags.outputs.tags }}
139+
cache-from: type=gha
140+
cache-to: type=gha,mode=max
141+
142+
- name: Extract image manifest
143+
if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
144+
run: |
145+
PRIMARY_TAG=$(printf '%s\n' "${{ steps.tags.outputs.tags }}" | head -n1)
146+
docker pull "$PRIMARY_TAG"
147+
docker run --rm --entrypoint cat "$PRIMARY_TAG" /image-manifest-apt.tsv > image-manifest-apt.tsv
148+
wc -l image-manifest-apt.tsv
149+
150+
- name: Upload image manifest
151+
if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
152+
uses: actions/upload-artifact@v4
153+
with:
154+
name: image-manifest-${{ steps.resolve.outputs.version }}-${{ steps.resolve.outputs.short }}
155+
path: image-manifest-apt.tsv
156+
retention-days: 90
157+
158+
- name: Clean up old images
159+
if: steps.check.outputs.exists != 'true' && steps.resolve.outputs.is_canary != 'true'
160+
continue-on-error: true
161+
uses: snok/container-retention-policy@v3.0.0
162+
with:
163+
account: quenty
164+
token: ${{ secrets.GITHUB_TOKEN }}
165+
image-names: nevermore-studio-linux
166+
cut-off: 30d
167+
keep-n-most-recent: 5
168+
169+
e2e:
170+
needs: build
171+
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
172+
runs-on: ubuntu-latest
173+
timeout-minutes: 30
174+
container:
175+
image: ghcr.io/quenty/nevermore-studio-linux:${{ needs.build.outputs.tag }}
176+
credentials:
177+
username: ${{ github.actor }}
178+
password: ${{ secrets.GITHUB_TOKEN }}
179+
options: --user studio --dns 8.8.8.8 --dns 8.8.4.4 --cap-add NET_RAW
180+
env:
181+
ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }}
182+
steps:
183+
- name: Start display server and Wine networking
184+
run: |
185+
# GitHub Actions overrides the container ENTRYPOINT, so we must
186+
# start Xvfb + openbox and refresh Wine networking manually.
187+
echo "Wine prefix before Xvfb:"
188+
ls -la $WINEPREFIX/system.reg $WINEPREFIX/user.reg 2>/dev/null || echo " No prefix files at $WINEPREFIX"
189+
ls -la /home/studio/.wine/system.reg 2>/dev/null || echo " No prefix at /home/studio/.wine"
190+
191+
Xvfb "${DISPLAY:-:99}" -screen 0 1024x768x24 &
192+
sleep 0.5
193+
DISPLAY="${DISPLAY:-:99}" openbox &
194+
sleep 0.5
195+
# Re-detect network interfaces so Wine sees the runtime network
196+
wineboot -u > /dev/null 2>&1 || true
197+
198+
echo "Wine ipconfig after wineboot -u:"
199+
wine ipconfig /all 2>/dev/null || echo " ipconfig failed"
200+
201+
- name: Checkout repository
202+
uses: actions/checkout@v6
203+
204+
- name: Install aftman tools
205+
run: aftman install --no-trust-check
206+
207+
- name: Setup pnpm
208+
uses: pnpm/action-setup@v4
209+
210+
- name: Setup registries
211+
run: |
212+
if [ -n "$GITHUB_TOKEN" ]; then
213+
echo -e "\n//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> .npmrc
214+
echo -e "\n//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> ~/.npmrc
215+
fi
216+
if [ -n "$NPM_TOKEN" ]; then
217+
echo -e "\n//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> .npmrc
218+
echo -e "\n//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc
219+
fi
220+
env:
221+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
222+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
223+
224+
- name: Install dependencies
225+
run: pnpm install --frozen-lockfile
226+
227+
- name: Build all tools
228+
run: pnpm -r --filter './tools/**' run build
229+
230+
- name: Install studio-bridge CLI
231+
run: npm install --ignore-scripts --force -g .
232+
working-directory: tools/studio-bridge
233+
234+
- name: Verify environment health (pre-auth)
235+
run: studio-bridge linux status
236+
237+
- name: Diagnose Wine networking
238+
if: failure()
239+
run: |
240+
echo "=== /etc/resolv.conf ==="
241+
cat /etc/resolv.conf
242+
echo ""
243+
echo "=== Host DNS test (curl) ==="
244+
curl -sI https://clientsettingscdn.roblox.com/ 2>&1 | head -5 || echo "curl failed"
245+
echo ""
246+
echo "=== /sys/class/net ==="
247+
ls -la /sys/class/net/ 2>/dev/null || echo "/sys/class/net not accessible"
248+
echo ""
249+
echo "=== /proc/net/route ==="
250+
cat /proc/net/route 2>/dev/null || echo "/proc/net/route not accessible"
251+
echo ""
252+
echo "=== /proc/net/if_inet6 ==="
253+
cat /proc/net/if_inet6 2>/dev/null || echo "No IPv6 info"
254+
echo ""
255+
echo "=== Wine ipconfig (before wineboot -u) ==="
256+
wine ipconfig /all 2>/dev/null || echo "wine ipconfig failed"
257+
echo ""
258+
echo "=== Running wineboot -u ==="
259+
WINEDEBUG=+nsi wineboot -u 2>&1 | head -30 || echo "wineboot -u failed"
260+
echo ""
261+
echo "=== Wine ipconfig (after wineboot -u) ==="
262+
wine ipconfig /all 2>/dev/null || echo "wine ipconfig failed"
263+
echo ""
264+
echo "=== Wine prefix files ==="
265+
ls -la $WINEPREFIX/system.reg $WINEPREFIX/user.reg 2>/dev/null || echo "No Wine prefix files"
266+
267+
- name: Inject authentication
268+
id: auth
269+
if: ${{ env.ROBLOSECURITY != '' }}
270+
shell: bash
271+
run: |
272+
set +e
273+
studio-bridge linux inject-credentials --verbose 2>&1 | tee inject-output.txt
274+
exit_code=${PIPESTATUS[0]}
275+
set -e
276+
if [ $exit_code -ne 0 ]; then
277+
if grep -qE 'cookie is invalid or expired \(HTTP (401|403)\)' inject-output.txt; then
278+
echo "::warning::ROBLOSECURITY cookie is stale (HTTP 401/403). Skipping execute step — rotate the secret to re-enable e2e."
279+
echo "cookie_stale=true" >> "$GITHUB_OUTPUT"
280+
exit 0
281+
fi
282+
exit $exit_code
283+
fi
284+
env:
285+
ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }}
286+
287+
- name: Execute script through Studio bridge
288+
if: ${{ env.ROBLOSECURITY != '' && steps.auth.outputs.cookie_stale != 'true' }}
289+
run: studio-bridge process run --verbose --timeout 60000 'print("E2E test passed!")'
290+
timeout-minutes: 5
291+
env:
292+
ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }}
293+
294+
- name: Print logs
295+
if: always()
296+
run: |
297+
echo "=== Environment ==="
298+
echo "DISPLAY=$DISPLAY WINEPREFIX=$WINEPREFIX STUDIO_DIR=$STUDIO_DIR HOME=$HOME"
299+
echo "Xvfb running: $(pgrep -x Xvfb > /dev/null && echo yes || echo no)"
300+
echo "openbox running: $(pgrep -x openbox > /dev/null && echo yes || echo no)"
301+
echo "Wine procs: $(pgrep -c wine 2>/dev/null || echo 0)"
302+
echo ""
303+
echo "=== Wine log (last 100 lines) ==="
304+
tail -100 /tmp/studio-bridge-wine.log 2>/dev/null || echo "No Wine log"
305+
echo ""
306+
echo "=== Studio logs ==="
307+
find $WINEPREFIX/drive_c/users/ -name "*.log" -path "*/Roblox/logs/*" 2>/dev/null | head -5
308+
tail -50 $WINEPREFIX/drive_c/users/*/AppData/Local/Roblox/logs/*.log 2>/dev/null || echo "No Studio logs"
309+
echo ""
310+
echo "=== Wine prefix check ==="
311+
ls -la $WINEPREFIX/system.reg 2>/dev/null || echo "No system.reg at WINEPREFIX=$WINEPREFIX"
312+
ls -la /home/studio/.wine/system.reg 2>/dev/null || echo "No system.reg at /home/studio/.wine"
313+
314+
- name: Upload logs
315+
if: always()
316+
uses: actions/upload-artifact@v4
317+
with:
318+
name: studio-bridge-logs
319+
path: |
320+
/tmp/studio-bridge-wine.log
321+
/home/studio/.wine/drive_c/users/*/AppData/Local/Roblox/logs/
322+
if-no-files-found: ignore

0 commit comments

Comments
 (0)