# Builds Frame Studio for every computer it runs on, plus a same-commit Frame Home payload, on branch pushes, # pull requests, and by hand (Actions > build > Run workflow). A push to main replaces the Studio "latest" pre-release # and includes the Frame Home installer. A tag "v*" publishes a versioned release containing both products. name: build on: push: branches: ['**'] tags: ['v*'] pull_request: workflow_dispatch: permissions: contents: read # A newer push to the same branch replaces a run still going (its builds would be out of date anyway). concurrency: group: build-${{ github.ref }} cancel-in-progress: true # Every Studio build knows the commit it's from (studio/updater.h compares it with the latest release's). env: FRAME_BUILD: ${{ github.sha }} FRAME_HOME_PAYLOAD_DIR: ${{ github.workspace }}/build/frame-home/payload jobs: linux: name: Linux ${{ matrix.arch }} needs: [frame-home] strategy: fail-fast: false matrix: include: - { arch: x64, runner: ubuntu-24.04, extra: "" } - { arch: arm64, runner: ubuntu-24.04-arm, extra: "" } runs-on: ${{ matrix.runner }} timeout-minutes: 45 env: FRAME_USE_CCACHE: "1" CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 500M CCACHE_COMPILERCHECK: content APT_CACHE_DIR: ${{ github.workspace }}/.apt-cache steps: - name: Check out source uses: actions/checkout@v4 with: fetch-depth: 0 # all of main: the version shown is VERSION + its commit count - name: Restore compiler cache uses: actions/cache@v5 with: path: ${{ github.workspace }}/.ccache key: ccache-studio-linux-${{ matrix.arch }}-${{ github.sha }} restore-keys: ccache-studio-linux-${{ matrix.arch }}- - name: Restore apt package cache uses: actions/cache@v5 with: path: .apt-cache/archives key: apt-ubuntu-24.04-${{ matrix.arch }}-${{ hashFiles('.github/workflows/build.yml') }} restore-keys: apt-ubuntu-24.04-${{ matrix.arch }}- - name: Download Frame Home payload uses: actions/download-artifact@v4 with: name: FrameHome-payload path: build/frame-home/payload - name: Tools and X11 headers run: | sudo mkdir -p "$APT_CACHE_DIR/archives/partial" sudo chown _apt:root "$APT_CACHE_DIR/archives/partial" sudo apt-get update sudo apt-get -o Dir::Cache::archives="$APT_CACHE_DIR/archives" install -y ccache ninja-build cmake libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxkbcommon-dev ${{ matrix.extra }} sudo chown -R "$(id -u):$(id -g)" "$APT_CACHE_DIR/archives" - name: Build run: sh scripts/build-studio.sh linux-${{ matrix.arch }} - name: Show compiler cache statistics run: ccache --show-stats - name: Upload Linux build uses: actions/upload-artifact@v4 with: name: FrameStudio-linux-${{ matrix.arch }} path: build/stage/FrameStudio-linux-${{ matrix.arch }} windows: name: Windows ${{ matrix.arch }} needs: [frame-home] strategy: fail-fast: false matrix: arch: [x64, arm64] runs-on: windows-latest timeout-minutes: 45 steps: - name: Check out source uses: actions/checkout@v4 with: fetch-depth: 0 # all of main: the version shown is VERSION + its commit count - name: Download Frame Home payload uses: actions/download-artifact@v4 with: name: FrameHome-payload path: build/frame-home/payload - name: Build (MSVC) shell: cmd run: studio\build_windows.bat ${{ matrix.arch }} "%GITHUB_WORKSPACE%\build\windows-${{ matrix.arch }}" - name: Package shell: pwsh run: | $name = "FrameStudio-windows-${{ matrix.arch }}" $stage = "build/stage/$name" New-Item -ItemType Directory -Force $stage | Out-Null Copy-Item "build/windows-${{ matrix.arch }}/Frame Studio.exe" $stage Copy-Item -Recurse "build/windows-${{ matrix.arch }}/frame" "$stage/frame" Copy-Item LICENSE "$stage/LICENSE.txt" - name: Upload Windows build uses: actions/upload-artifact@v4 with: name: FrameStudio-windows-${{ matrix.arch }} path: build/stage/FrameStudio-windows-${{ matrix.arch }} macos: name: macOS ${{ matrix.arch }} needs: [frame-home] strategy: fail-fast: false matrix: include: - { arch: arm64, runner: macos-14 } runs-on: ${{ matrix.runner }} timeout-minutes: 45 env: FRAME_USE_CCACHE: "1" CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 500M CCACHE_COMPILERCHECK: content steps: - name: Check out source uses: actions/checkout@v4 with: fetch-depth: 0 # all of main: the version shown is VERSION + its commit count - name: Restore compiler cache uses: actions/cache@v5 with: path: ${{ github.workspace }}/.ccache key: ccache-studio-macos-${{ matrix.arch }}-${{ github.sha }} restore-keys: ccache-studio-macos-${{ matrix.arch }}- - name: Restore Homebrew package cache uses: actions/cache@v5 with: path: ~/Library/Caches/Homebrew/downloads key: brew-macos-${{ matrix.arch }}-${{ hashFiles('.github/workflows/build.yml') }} restore-keys: brew-macos-${{ matrix.arch }}- - name: Download Frame Home payload uses: actions/download-artifact@v4 with: name: FrameHome-payload path: build/frame-home/payload - name: Tools and MoltenVK (Vulkan on Metal, bundled into the app) run: brew install ccache ninja molten-vk - name: Build run: sh scripts/build-studio.sh macos-${{ matrix.arch }} - name: Show compiler cache statistics run: ccache --show-stats - name: Upload macOS build uses: actions/upload-artifact@v4 with: name: FrameStudio-macos-${{ matrix.arch }} path: build/stage/FrameStudio-macos-${{ matrix.arch }} frame-home: name: Build Frame Home payload runs-on: ubuntu-24.04-arm timeout-minutes: 45 env: CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 500M CCACHE_COMPILERCHECK: content APT_CACHE_DIR: ${{ github.workspace }}/.apt-cache steps: - name: Check out source uses: actions/checkout@v4 - name: Restore compiler cache uses: actions/cache@v5 with: path: ${{ github.workspace }}/.ccache key: ccache-frame-home-arm64-${{ github.sha }} restore-keys: ccache-frame-home-arm64- - name: Restore apt package cache uses: actions/cache@v5 with: path: .apt-cache/archives # (its own key: the Linux arm64 Studio job caches other packages, and the first to save a key keeps it) key: apt-frame-home-ubuntu-24.04-arm64-${{ hashFiles('.github/workflows/build.yml') }} restore-keys: apt-frame-home-ubuntu-24.04-arm64- - name: Install Frame Home build dependencies run: | set -euo pipefail sudo mkdir -p "$APT_CACHE_DIR/archives/partial" sudo chown _apt:root "$APT_CACHE_DIR/archives/partial" sudo apt-get update sudo apt-get -o Dir::Cache::archives="$APT_CACHE_DIR/archives" install -y ccache cmake ninja-build gcc-13 g++-13 glslc zip libdeflate-dev sudo chown -R "$(id -u):$(id -g)" "$APT_CACHE_DIR/archives" - name: Build payload and installer run: sh scripts/build-frame-home.sh --ci - name: Tests (tests_loco, ctest) run: | cmake -S . -B build/frame-home-cmake-ci -DFRAME_TESTS=ON cmake --build build/frame-home-cmake-ci --target frame-tests --parallel ctest --test-dir build/frame-home-cmake-ci --output-on-failure - name: Show compiler cache statistics run: ccache --show-stats - name: Upload Frame Home payload uses: actions/upload-artifact@v4 with: name: FrameHome-payload path: build/frame-home/payload - name: Upload Frame Home installer uses: actions/upload-artifact@v4 with: name: FrameHome-installer path: build/frame-home/FrameHome-installer-stage/FrameHome-installer if-no-files-found: error release: name: Publish # "latest" gets every build that finished (a failed or still-slow platform just isn't in it); a version release # needs them all. We don't wait on macOS: its runners can sit in GitHub's queue for hours, and since a push # cancels the previous run, waiting meant no run ever reached this job. Both Mac builds still upload and are # attached whenever they finish in time. if: ${{ !cancelled() && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') }} needs: [linux, windows, frame-home] runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: contents: write id-token: write attestations: write env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} steps: # Every push to main is also a numbered release: VERSION (major.minor) and the commits since VERSION last # changed, so the commit that bumps it is .0 (cmake/frame_version.cmake works out the same for the apps). - name: Check out history (for the version number) if: github.ref == 'refs/heads/main' uses: actions/checkout@v4 with: path: src fetch-depth: 0 - name: Version if: github.ref == 'refs/heads/main' run: | bump=$(git -C src log -1 --format=%H -- VERSION) version="v$(tr -d ' \r\n' < src/VERSION).$(git -C src rev-list --count "$bump..HEAD")" echo "VERSION_TAG=$version" >> "$GITHUB_ENV" echo "$version" - name: Download Studio build artifacts uses: actions/download-artifact@v4 with: pattern: FrameStudio-* path: dist merge-multiple: false - name: Download Frame Home payload uses: actions/download-artifact@v4 with: name: FrameHome-payload path: ${{ runner.temp }}/frame-home-payload - name: Download Frame Home installer uses: actions/download-artifact@v4 with: name: FrameHome-installer path: dist/FrameHome-installer - name: Rebuild release archives run: | for package in dist/FrameStudio-linux-*; do [ -d "$package" ] || continue name=${package##*/} # (artifacts don't keep file modes: the program itself has to be executable again) chmod +x "$package/Frame Studio" if [ -d "$package/frame/bin" ]; then chmod +x "$package/frame/install.sh" "$package/frame/bin/"* fi if [ -d "$package/pc/bin" ]; then chmod +x "$package/pc/install.sh" "$package/pc/bin/"* fi tar -czf "dist/$name.tar.gz" -C dist "$name" rm -rf "$package" done for package in dist/FrameStudio-windows-* dist/FrameStudio-macos-*; do [ -d "$package" ] || continue name=${package##*/} if [ -d "$package/frame/bin" ]; then chmod +x "$package/frame/install.sh" "$package/frame/bin/"* fi if [[ "$name" == FrameStudio-macos-* ]]; then chmod +x "$package/Frame Studio.app/Contents/MacOS/Frame Studio" fi (cd dist && zip -qr "$name.zip" "$name") rm -rf "$package" done chmod +x dist/FrameHome-installer/install-frame-home.sh \ dist/FrameHome-installer/payload/install.sh \ dist/FrameHome-installer/payload/bin/* (cd dist && zip -qr FrameHome-installer.zip FrameHome-installer) rm -rf dist/FrameHome-installer python3 - <<'PY' from pathlib import Path from zipfile import ZipFile for path in Path("dist").glob("*.zip"): with ZipFile(path) as archive: nested = [name for name in archive.namelist() if name.lower().endswith(".zip")] if nested: raise SystemExit(f"{path} contains nested ZIP files: {nested}") PY # What Frame Studio (studio/updater.h) and the headset's Environments panel check for updates: this commit, # Frame Home's identity (a hash of the headset programs, the same one the panel works out for what's installed) # and the files in this release. - name: latest.json run: | set -euo pipefail # (a payload file missing must fail here, not hash what's left) home=$(cd "$RUNNER_TEMP/frame-home-payload" && sha256sum bin/frame-home bin/frame-switcher bin/fenv-tool layer/libframe_home_layer.so layer/env_ps.spv | cut -c1-64 | sha256sum | cut -c1-64) files=$(ls dist | python3 -c 'import json, sys; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))') # Each file's SHA-256 as "sha256-": Frame Studio checks its download against it before installing. hashes=$(cd dist && for f in *; do if [ -f "$f" ] && [ "$f" != latest.json ]; then printf ', "sha256-%s": "%s"' "$f" "$(sha256sum "$f" | cut -c1-64)"; fi; done) printf '{"build": "%s", "home": "%s", "date": "%s", "files": %s%s}\n' "$GITHUB_SHA" "$home" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$files" "$hashes" > dist/latest.json cat dist/latest.json - name: Attest Studio release archives uses: actions/attest-build-provenance@v2 with: subject-path: dist/FrameStudio-* - name: Attest Frame Home installer uses: actions/attest-build-provenance@v2 with: subject-path: dist/FrameHome-installer.zip - name: Version release (tag v*) if: ${{ startsWith(github.ref, 'refs/tags/v') && !contains(needs.*.result, 'failure') }} run: | # A version release has every platform: this job doesn't wait for macOS (see above), so without its build # here it stops; run this job again once the macOS build has finished. if ! ls dist/FrameStudio-macos-*.zip > /dev/null 2>&1; then echo "::error::the macOS build isn't here yet: re-run this job once it has finished"; exit 1 fi gh release create "${{ github.ref_name }}" dist/* --repo "$REPO" --title "Frame Studio ${{ github.ref_name }}" --generate-notes # The numbered release (push to main): GitHub's "Latest release", with the changes since the previous numbered # one. Only when no build failed; a re-run of the same commit finds it there and leaves it. - name: Numbered release (push to main) if: ${{ github.ref == 'refs/heads/main' && !contains(needs.*.result, 'failure') }} run: | if gh release view "$VERSION_TAG" --repo "$REPO" > /dev/null 2>&1; then # A re-run of this commit: done already. The tag on another commit (pushed by hand, the same name as this # number): say so rather than skip, or this push would never be released. if [ "$(git -C src rev-list -n 1 "$VERSION_TAG" 2>/dev/null)" = "$GITHUB_SHA" ]; then echo "$VERSION_TAG is already released"; exit 0; fi echo "::error::$VERSION_TAG already exists on another commit (a tag made by hand?): bump VERSION or remove that tag"; exit 1 fi previous=$(gh release list --repo "$REPO" --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName // empty') # The notes open with CHANGELOG.md's top section when the changelog changed since the previous release # (whatever number this push gets), then GitHub's list of changes. They're also what the Discord's #releases # channel gets (the repository's release webhook). : > "$RUNNER_TEMP/notes.md" if [ -z "$previous" ] || ! git -C src diff --quiet "$previous" HEAD -- CHANGELOG.md 2>/dev/null; then awk '/^## / { if (on) exit; on = 1; next } on' src/CHANGELOG.md > "$RUNNER_TEMP/notes.md" fi cat "$RUNNER_TEMP/notes.md" gh release create "$VERSION_TAG" dist/* --repo "$REPO" --target "${{ github.sha }}" --latest \ --title "Frame Studio $VERSION_TAG" --notes-file "$RUNNER_TEMP/notes.md" --generate-notes ${previous:+--notes-start-tag "$previous"} # The rolling "latest" pre-release: where installed Studios and headsets look for updates (latest.json). # Updated in place, never deleted first: a run cancelled midway (a newer push) or an update check meanwhile # always finds a whole release. The files go up first and latest.json last, so it never names files that aren't # there yet; then what this build no longer makes is removed and the tag moved to this commit. - name: Latest build (push to main) if: github.ref == 'refs/heads/main' run: | set -euo pipefail title="Latest build (${GITHUB_SHA::7})" notes="Built automatically from ${{ github.sha }} on $(date -u '+%Y-%m-%d %H:%M UTC'). Replaced by every push to main." if ! gh release view latest --repo "$REPO" > /dev/null 2>&1; then gh release create latest dist/* --repo "$REPO" --prerelease --target "${{ github.sha }}" --title "$title" --notes "$notes" exit 0 fi find dist -maxdepth 1 -type f ! -name latest.json -print0 | xargs -0 gh release upload latest --repo "$REPO" --clobber gh release upload latest dist/latest.json --repo "$REPO" --clobber for asset in $(gh release view latest --repo "$REPO" --json assets --jq '.assets[].name'); do [ -e "dist/$asset" ] || gh release delete-asset latest "$asset" --repo "$REPO" --yes done gh api -X PATCH "repos/$REPO/git/refs/tags/latest" -f sha="$GITHUB_SHA" -F force=true > /dev/null gh release edit latest --repo "$REPO" --title "$title" --notes "$notes"