Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e8ce092
chore(ci): add TypeScript lint/test scripts and dedicated typescript …
Quenty Apr 27, 2026
e4bee0d
chore(format): apply prettier to pre-existing TypeScript files for CI…
Quenty Apr 27, 2026
3817fa4
chore: update pnpm lockfile and devcontainer for studio-bridge work
Quenty Apr 27, 2026
d8d0278
feat(cli-output-helpers): add Reporter framework primitives and Resul…
Quenty Apr 27, 2026
ca81497
refactor(auth): consolidate auth into nevermore-cli-helpers with cook…
Quenty Apr 27, 2026
7f2cbea
feat(studio-bridge): add v2 protocol, transport, and persistent bridg…
Quenty Apr 27, 2026
209fbbf
feat(studio-bridge): rewrite plugin template with action handlers, sc…
Quenty Apr 27, 2026
b4054f3
feat(studio-bridge): add CLI commands (sessions, exec, run, query, sc…
Quenty Apr 27, 2026
d040178
feat(studio-bridge): add persistent plugin manager
Quenty Apr 27, 2026
1ebe30e
refactor(template-helpers): add Linux fallback for rojo --plugin and …
Quenty Apr 28, 2026
8c03d34
feat(studio-bridge): add Linux/Wine support and Docker image for head…
Quenty Apr 28, 2026
693942f
refactor(studio-bridge): drop v1 protocol path
Quenty Apr 28, 2026
47ca802
fix(studio-bridge): address review feedback (security, bugs, cleanup)
Quenty Apr 29, 2026
1121b01
fix(studio-bridge): correlate plugin responses via PendingRequestMap
Quenty Apr 29, 2026
125381a
feat(studio-bridge): validate HostEnvelope action via zod schemas
Quenty Apr 29, 2026
341e4f5
test(studio-bridge): cover waitForSessionsToSettleAsync settle path
Quenty Apr 29, 2026
4e4c9bb
chore(studio-bridge): drop unused CommandRegistry.discoverAsync
Quenty May 1, 2026
cdd834e
refactor(studio-bridge,cli-output-helpers): simplify single-result CL…
Quenty May 1, 2026
31e06ca
chore(studio-bridge): emit apt manifest as Docker build artifact
Quenty May 1, 2026
fc4ef74
refactor(studio-bridge): use EncodingService:Base64Encode for screenshot
Quenty May 1, 2026
2f9625b
chore(vscode): enable file nesting for init.lua and sibling files
Quenty May 14, 2026
545fc9b
docs(studio-bridge): trim programmatic API and protocol sections from…
Quenty May 14, 2026
9b9d22e
chore(ci): skip studio-linux-ci e2e when ROBLOSECURITY cookie is stale
Quenty May 14, 2026
f79bb4c
Merge branch 'main' into feat/studio-bridge
Quenty May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"version": "22",
"pnpmVersion": "10.27.0"
},
"ghcr.io/devcontainers/features/github-cli:1": {}
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},

"customizations": {
Expand Down
322 changes: 322 additions & 0 deletions .github/workflows/studio-linux-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,322 @@
name: studio-linux-ci

on:
schedule:
- cron: '0 3 * * *' # Nightly 03:00 UTC
workflow_dispatch:
inputs:
studio_version:
description: 'Override Studio version hash (leave empty for latest)'
required: false
push:
branches: [main]
paths:
- 'tools/studio-bridge/docker/**'
- 'tools/studio-bridge/src/**'
- '.github/workflows/studio-linux-ci.yml'
pull_request:
paths:
- 'tools/studio-bridge/docker/**'
- 'tools/studio-bridge/src/**'
- 'tools/nevermore-cli-helpers/src/auth/**'
- '.github/workflows/studio-linux-ci.yml'

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true

env:
REGISTRY: ghcr.io
IMAGE_NAME: quenty/nevermore-studio-linux

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Resolve Studio version
id: resolve
run: |
if [ -n "${{ inputs.studio_version }}" ]; then
VERSION="${{ inputs.studio_version }}"
else
VERSION=$(curl -s https://clientsettingscdn.roblox.com/v2/client-version/WindowsStudio64 | jq -r .clientVersionUpload)
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "short=${VERSION:0:12}" >> "$GITHUB_OUTPUT"
echo "Resolved Studio version: $VERSION"

# Canary builds for PRs and non-main branches
BRANCH="${{ github.head_ref || github.ref_name }}"
if [ "$BRANCH" != "main" ]; then
BRANCH_SLUG="${BRANCH//\//-}"
SHORT_SHA="${GITHUB_SHA:0:8}"
echo "is_canary=true" >> "$GITHUB_OUTPUT"
echo "canary_tag=canary-${BRANCH_SLUG}-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "branch_tag=canary-${BRANCH_SLUG}" >> "$GITHUB_OUTPUT"
echo "Canary build: canary-${BRANCH_SLUG}-${SHORT_SHA}"
else
echo "is_canary=false" >> "$GITHUB_OUTPUT"
echo "canary_tag=" >> "$GITHUB_OUTPUT"
fi

- name: Compute image tag for downstream jobs
id: tag
run: |
if [ "${{ steps.resolve.outputs.is_canary }}" = "true" ]; then
echo "tag=${{ steps.resolve.outputs.branch_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=latest" >> "$GITHUB_OUTPUT"
fi

- name: Check if image already exists
id: check
run: |
# Always rebuild canary images
if [ "${{ steps.resolve.outputs.is_canary }}" = "true" ]; then
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Canary build — always rebuild"
exit 0
fi

if docker manifest inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.version }} > /dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Image already exists for version ${{ steps.resolve.outputs.version }}, skipping build"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Image not found, will build"
fi
env:
DOCKER_CLI_EXPERIMENTAL: enabled

- name: Checkout repository
if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
uses: actions/checkout@v6

- name: Set up Docker Buildx
if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
uses: docker/setup-buildx-action@v3

- name: Log in to GitHub Container Registry
if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Compute image tags
if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
id: tags
run: |
if [ "${{ steps.resolve.outputs.is_canary }}" = "true" ]; then
# Tag with both the SHA-specific and stable branch tags
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.canary_tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.branch_tag }}"
else
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.version }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.short }}"
fi
echo "tags<<ENDOFTAGS" >> "$GITHUB_OUTPUT"
echo "$TAGS" >> "$GITHUB_OUTPUT"
echo "ENDOFTAGS" >> "$GITHUB_OUTPUT"

- name: Build and push image
if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: tools/studio-bridge/docker
build-contexts: workspace=.
build-args: |
STUDIO_VERSION=${{ steps.resolve.outputs.version }}
push: true
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Extract image manifest
if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
run: |
PRIMARY_TAG=$(printf '%s\n' "${{ steps.tags.outputs.tags }}" | head -n1)
docker pull "$PRIMARY_TAG"
docker run --rm --entrypoint cat "$PRIMARY_TAG" /image-manifest-apt.tsv > image-manifest-apt.tsv
wc -l image-manifest-apt.tsv

- name: Upload image manifest
if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
uses: actions/upload-artifact@v4
with:
name: image-manifest-${{ steps.resolve.outputs.version }}-${{ steps.resolve.outputs.short }}
path: image-manifest-apt.tsv
retention-days: 90

- name: Clean up old images
if: steps.check.outputs.exists != 'true' && steps.resolve.outputs.is_canary != 'true'
continue-on-error: true
uses: snok/container-retention-policy@v3.0.0
with:
account: quenty
token: ${{ secrets.GITHUB_TOKEN }}
image-names: nevermore-studio-linux
cut-off: 30d
keep-n-most-recent: 5

e2e:
needs: build
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/quenty/nevermore-studio-linux:${{ needs.build.outputs.tag }}
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
options: --user studio --dns 8.8.8.8 --dns 8.8.4.4 --cap-add NET_RAW
env:
ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }}
steps:
- name: Start display server and Wine networking
run: |
# GitHub Actions overrides the container ENTRYPOINT, so we must
# start Xvfb + openbox and refresh Wine networking manually.
echo "Wine prefix before Xvfb:"
ls -la $WINEPREFIX/system.reg $WINEPREFIX/user.reg 2>/dev/null || echo " No prefix files at $WINEPREFIX"
ls -la /home/studio/.wine/system.reg 2>/dev/null || echo " No prefix at /home/studio/.wine"

Xvfb "${DISPLAY:-:99}" -screen 0 1024x768x24 &
sleep 0.5
DISPLAY="${DISPLAY:-:99}" openbox &
sleep 0.5
# Re-detect network interfaces so Wine sees the runtime network
wineboot -u > /dev/null 2>&1 || true

echo "Wine ipconfig after wineboot -u:"
wine ipconfig /all 2>/dev/null || echo " ipconfig failed"

- name: Checkout repository
uses: actions/checkout@v6

- name: Install aftman tools
run: aftman install --no-trust-check

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup registries
run: |
if [ -n "$GITHUB_TOKEN" ]; then
echo -e "\n//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> .npmrc
echo -e "\n//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> ~/.npmrc
fi
if [ -n "$NPM_TOKEN" ]; then
echo -e "\n//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> .npmrc
echo -e "\n//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build all tools
run: pnpm -r --filter './tools/**' run build

- name: Install studio-bridge CLI
run: npm install --ignore-scripts --force -g .
working-directory: tools/studio-bridge

- name: Verify environment health (pre-auth)
run: studio-bridge linux status

- name: Diagnose Wine networking
if: failure()
run: |
echo "=== /etc/resolv.conf ==="
cat /etc/resolv.conf
echo ""
echo "=== Host DNS test (curl) ==="
curl -sI https://clientsettingscdn.roblox.com/ 2>&1 | head -5 || echo "curl failed"
echo ""
echo "=== /sys/class/net ==="
ls -la /sys/class/net/ 2>/dev/null || echo "/sys/class/net not accessible"
echo ""
echo "=== /proc/net/route ==="
cat /proc/net/route 2>/dev/null || echo "/proc/net/route not accessible"
echo ""
echo "=== /proc/net/if_inet6 ==="
cat /proc/net/if_inet6 2>/dev/null || echo "No IPv6 info"
echo ""
echo "=== Wine ipconfig (before wineboot -u) ==="
wine ipconfig /all 2>/dev/null || echo "wine ipconfig failed"
echo ""
echo "=== Running wineboot -u ==="
WINEDEBUG=+nsi wineboot -u 2>&1 | head -30 || echo "wineboot -u failed"
echo ""
echo "=== Wine ipconfig (after wineboot -u) ==="
wine ipconfig /all 2>/dev/null || echo "wine ipconfig failed"
echo ""
echo "=== Wine prefix files ==="
ls -la $WINEPREFIX/system.reg $WINEPREFIX/user.reg 2>/dev/null || echo "No Wine prefix files"

- name: Inject authentication
id: auth
if: ${{ env.ROBLOSECURITY != '' }}
shell: bash
run: |
set +e
studio-bridge linux inject-credentials --verbose 2>&1 | tee inject-output.txt
exit_code=${PIPESTATUS[0]}
set -e
if [ $exit_code -ne 0 ]; then
if grep -qE 'cookie is invalid or expired \(HTTP (401|403)\)' inject-output.txt; then
echo "::warning::ROBLOSECURITY cookie is stale (HTTP 401/403). Skipping execute step — rotate the secret to re-enable e2e."
echo "cookie_stale=true" >> "$GITHUB_OUTPUT"
exit 0
fi
exit $exit_code
fi
env:
ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }}

- name: Execute script through Studio bridge
if: ${{ env.ROBLOSECURITY != '' && steps.auth.outputs.cookie_stale != 'true' }}
run: studio-bridge process run --verbose --timeout 60000 'print("E2E test passed!")'
timeout-minutes: 5
env:
ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }}

- name: Print logs
if: always()
run: |
echo "=== Environment ==="
echo "DISPLAY=$DISPLAY WINEPREFIX=$WINEPREFIX STUDIO_DIR=$STUDIO_DIR HOME=$HOME"
echo "Xvfb running: $(pgrep -x Xvfb > /dev/null && echo yes || echo no)"
echo "openbox running: $(pgrep -x openbox > /dev/null && echo yes || echo no)"
echo "Wine procs: $(pgrep -c wine 2>/dev/null || echo 0)"
echo ""
echo "=== Wine log (last 100 lines) ==="
tail -100 /tmp/studio-bridge-wine.log 2>/dev/null || echo "No Wine log"
echo ""
echo "=== Studio logs ==="
find $WINEPREFIX/drive_c/users/ -name "*.log" -path "*/Roblox/logs/*" 2>/dev/null | head -5
tail -50 $WINEPREFIX/drive_c/users/*/AppData/Local/Roblox/logs/*.log 2>/dev/null || echo "No Studio logs"
echo ""
echo "=== Wine prefix check ==="
ls -la $WINEPREFIX/system.reg 2>/dev/null || echo "No system.reg at WINEPREFIX=$WINEPREFIX"
ls -la /home/studio/.wine/system.reg 2>/dev/null || echo "No system.reg at /home/studio/.wine"

- name: Upload logs
if: always()
uses: actions/upload-artifact@v4
with:
name: studio-bridge-logs
path: |
/tmp/studio-bridge-wine.log
/home/studio/.wine/drive_c/users/*/AppData/Local/Roblox/logs/
if-no-files-found: ignore
Loading
Loading