Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/release-notes/bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ You need only the tablet and Wi-Fi — no computer.

If the installer shows a red **"Display services update needed"** card, it could not download the display services — check the Wi-Fi and tap **Check again**. Until they are updated, **3D will not work correctly** (content stays 2D, parallax is wrong, apps can freeze).

Upgrading the installer itself from an older build fails with *"App not installed"* (each build is signed with a different key for now) — uninstall the old **DisplayXR Installer** first; your DisplayXR apps are not affected.
**Already have DisplayXR Installer 0.4.1 or older? One time only:** uninstall the old **DisplayXR Installer** (*Settings → Apps → DisplayXR Installer → Uninstall*), then install this one — otherwise Android says *"App not installed"*. Your DisplayXR apps are not affected. Those older builds were signed with a temporary key; from 0.4.2 on every installer is signed with the same permanent key and updates in place.

---
31 changes: 28 additions & 3 deletions .github/workflows/build-android-bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ permissions:
jobs:
bundle:
runs-on: ubuntu-latest
# The installer app's signing key (build-android-installer.yml, "SIGNING"): reachable
# from main only. From any other branch the installer is debug-signed, and publish=true
# then fails at the installer step instead of shipping an APK tablets cannot update to.
environment: ${{ github.ref == 'refs/heads/main' && 'android-installer-release' || '' }}
steps:
- uses: actions/checkout@v5

Expand Down Expand Up @@ -270,14 +274,35 @@ jobs:
packages: ''

- name: Build the installer app
env:
KS_B64: ${{ secrets.ANDROID_INSTALLER_KEYSTORE_B64 }}
ANDROID_INSTALLER_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_INSTALLER_KEYSTORE_PASSWORD }}
ANDROID_INSTALLER_KEY_ALIAS: ${{ secrets.ANDROID_INSTALLER_KEY_ALIAS }}
ANDROID_INSTALLER_KEY_PASSWORD: ${{ secrets.ANDROID_INSTALLER_KEY_PASSWORD }}
PUBLISH: ${{ inputs.publish }}
run: |
set -euo pipefail
V=$(grep -E '^installerVersionName=' android-installer/gradle.properties | cut -d= -f2 | tr -d '[:space:]')
[ -n "$V" ] || { echo "::error::installerVersionName missing from android-installer/gradle.properties"; exit 1; }
OUT="DisplayXR-Installer-$V.apk"
b="_bundle/$BUNDLE"
( cd android-installer && ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug )
A=android-installer/app/build/outputs/apk/debug/app-debug.apk
# Same signing as build-android-installer.yml: the release key when this run has
# it (main), else the debug key. The keystore is a temp file, removed right after.
if [ -n "${KS_B64:-}" ]; then
export ANDROID_INSTALLER_KEYSTORE_FILE="$RUNNER_TEMP/dxr-installer-release.p12"
( umask 077; printf '%s' "$KS_B64" | base64 -d > "$ANDROID_INSTALLER_KEYSTORE_FILE" )
fi
( cd android-installer && ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleRelease )
rm -f "$RUNNER_TEMP/dxr-installer-release.p12"
A=android-installer/app/build/outputs/apk/release/app-release.apk
if android-installer/scripts/verify-release-signature.sh "$A"; then
OUT="DisplayXR-Installer-$V.apk"
elif [ "$PUBLISH" = true ] || [ -n "${KS_B64:-}" ]; then
echo "::error::the installer APK is not signed with the pinned release key — refusing to put it in a published bundle (dispatch from main)"
exit 1
else
echo "::warning::installer APK is DEBUG-signed (not main): fine for a CI-only bundle, never for a published one."
OUT="DisplayXR-Installer-$V-DEBUGKEY.apk"
fi
# Same rule as the public release: it must carry nothing embedded.
if unzip -Z1 "$A" | grep -E '^assets/cnsdk/|\.apk$'; then
echo "::error::the installer APK embeds files it must not"; exit 1
Expand Down
117 changes: 104 additions & 13 deletions .github/workflows/build-android-installer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ name: build-android-installer
#
# publish-bundle.yml calls this via workflow_call, so every public bundle release
# carries DisplayXR-Installer-<ver>.apk built from the same commit.
#
# SIGNING (README "Signing"). Every published installer is signed with ONE release key,
# because Android updates an installed app only from an APK signed by the same key. The
# key lives in the `android-installer-release` environment, whose deployment-branch policy
# admits `main` only — so a PR (fork or not), or a dispatch from any other branch, never
# sees it, and even a workflow edited on a branch cannot read it. Those runs build the
# release variant signed with the DEBUG key: it compiles, tests and installs, and it is
# unpublishable, because scripts/verify-release-signature.sh (run here, and again in
# publish-bundle.yml right before the release is created) refuses any signer but the one
# pinned in android-installer/release-signing-cert.sha256.

on:
pull_request:
Expand All @@ -43,17 +53,25 @@ on:
release_tag:
type: string
default: ""
# publish-bundle.yml passes true: fail HERE, before anything else of the release
# is built on top, if the APK is not signed with the pinned release key.
require_release_key:
type: boolean
default: false
outputs:
version:
description: "installerVersionName the APK was built with"
value: ${{ jobs.build.outputs.version }}
release_signed:
description: "'true' when the APK's signer is the pinned release key"
value: ${{ jobs.build.outputs.release_signed }}
artifact:
description: "Name of the uploaded artifact holding the installer APK + .sha256"
value: ${{ jobs.build.outputs.artifact }}
workflow_dispatch:
inputs:
release_tag:
description: "Attach the APK to this existing release (e.g. android-bundle-2026-09-22). Empty = CI artifact only."
description: "Attach the APK to this existing release (e.g. android-bundle-2026-09-22). Empty = CI artifact only. Requires the release key, so dispatch from main."
type: string
default: ""

Expand All @@ -63,9 +81,15 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
# The signing key's environment, on main only (its branch policy would refuse any other
# ref anyway, and a refused environment FAILS the job rather than skipping the key — so
# the condition keeps PR and branch runs building, debug-signed). For a workflow_call,
# github.* is the caller's: publish-bundle dispatched from main gets the key.
environment: ${{ (github.event_name != 'pull_request' && github.ref == 'refs/heads/main') && 'android-installer-release' || '' }}
outputs:
version: ${{ steps.ver.outputs.version }}
artifact: DisplayXR-Installer-${{ steps.ver.outputs.version }}
release_signed: ${{ steps.pin.outputs.release_signed }}
defaults:
run:
working-directory: android-installer
Expand Down Expand Up @@ -111,14 +135,72 @@ jobs:
- name: The services publishing script parses
run: bash -n scripts/make-services-manifest.sh

# Debug, not release, and deliberately: an unsigned release APK cannot be
# installed at all, and this repo signs nothing (see CLAUDE.md). The cost
# is stated in the README: each run signs with that runner's throwaway
# debug key, so upgrading the installer itself in place fails with
# INSTALL_FAILED_UPDATE_INCOMPATIBLE — uninstall the old one first. It
# does not affect anything the installer installs, only the installer.
# The keystore goes to a file outside the checkout (RUNNER_TEMP) and is deleted at the
# end of the job; only its PATH reaches Gradle. Absent secret = no file = the release
# variant falls back to the debug key (app/build.gradle.kts).
- name: Decode the release key (main only)
env:
KS_B64: ${{ secrets.ANDROID_INSTALLER_KEYSTORE_B64 }}
run: |
set -euo pipefail
if [ -z "${KS_B64:-}" ]; then
echo "No release key in this run (PR, fork, or not main) — the APK will be DEBUG-signed and unpublishable."
exit 0
fi
ks="$RUNNER_TEMP/dxr-installer-release.p12"
( umask 077; printf '%s' "$KS_B64" | base64 -d > "$ks" )
echo "ANDROID_INSTALLER_KEYSTORE_FILE=$ks" >> "$GITHUB_ENV"
echo "Release key decoded ($(wc -c < "$ks") bytes)."

# The release variant, always: signed with the release key when the step above found
# one, with the debug key otherwise. One task and one output path for both cases, so
# what a PR builds is what a release builds, minus the key.
- name: Assemble
run: ./gradlew --no-daemon :app:assembleDebug
env:
ANDROID_INSTALLER_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_INSTALLER_KEYSTORE_PASSWORD }}
ANDROID_INSTALLER_KEY_ALIAS: ${{ secrets.ANDROID_INSTALLER_KEY_ALIAS }}
ANDROID_INSTALLER_KEY_PASSWORD: ${{ secrets.ANDROID_INSTALLER_KEY_PASSWORD }}
run: ./gradlew --no-daemon :app:assembleRelease

# Is it signed with THE key? Judged from the APK's bytes against the committed pin.
# Required (and so a failure) when a release depends on it or when the key was
# present — a key that is present but produces the wrong signer is a broken secret,
# not a PR. Otherwise it is reported and the run stays green.
- name: Release-key pin check
id: pin
env:
REQUIRE: ${{ inputs.require_release_key == true || inputs.release_tag != '' || env.ANDROID_INSTALLER_KEYSTORE_FILE != '' }}
run: |
set -euo pipefail
A=app/build/outputs/apk/release/app-release.apk
if scripts/verify-release-signature.sh "$A"; then
echo "release_signed=true" >> "$GITHUB_OUTPUT"
else
echo "release_signed=false" >> "$GITHUB_OUTPUT"
if [ "$REQUIRE" = true ]; then
echo "::error::this APK must be signed with the pinned release key and is not — see above"
exit 1
fi
echo "::warning::DEBUG-signed APK (no release key in this run): fine for testing, refused by every publish path."
fi

# Proves the check above can fail: the same bytes re-signed with a key made up on the
# spot must be REFUSED. Without this, a verifier that always passed would look exactly
# like a working one on main.
- name: The pin check refuses a foreign key
run: |
set -euo pipefail
BT=$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1)
t="$RUNNER_TEMP/negative"; mkdir -p "$t"
keytool -genkeypair -keystore "$t/foreign.p12" -storetype PKCS12 -alias x -keyalg EC -groupname secp256r1 \
-validity 1 -dname "CN=Not DisplayXR" -storepass throwaway -keypass throwaway >/dev/null 2>&1
"$BT/apksigner" sign --ks "$t/foreign.p12" --ks-pass pass:throwaway --ks-key-alias x \
--out "$t/foreign.apk" app/build/outputs/apk/release/app-release.apk
if scripts/verify-release-signature.sh "$t/foreign.apk"; then
echo "::error::verify-release-signature.sh ACCEPTED an APK signed with a throwaway key — the release gate is broken"
exit 1
fi
echo "OK: a foreign-key APK is refused."

# The APK goes on PUBLIC releases, so prove from its bytes that it
# carries nothing embedded: no assets/cnsdk/ tree and no nested APK of any
Expand All @@ -127,7 +209,7 @@ jobs:
- name: The APK carries no vendor bytes
run: |
set -euo pipefail
A=app/build/outputs/apk/debug/app-debug.apk
A=app/build/outputs/apk/release/app-release.apk
if unzip -Z1 "$A" | grep -E '^assets/cnsdk/|\.apk$'; then
echo "::error::the installer APK embeds files it must not — it is published on public releases"
exit 1
Expand All @@ -138,8 +220,12 @@ jobs:
id: apk
run: |
set -euo pipefail
SRC=app/build/outputs/apk/debug/app-debug.apk
OUT="DisplayXR-Installer-${{ steps.ver.outputs.version }}.apk"
SRC=app/build/outputs/apk/release/app-release.apk
# A debug-signed build says so in its file name, so a PR artifact passed around
# by hand cannot be mistaken for a release (it would not update a tablet's copy).
SUFFIX=""
[ "${{ steps.pin.outputs.release_signed }}" = true ] || SUFFIX="-DEBUGKEY"
OUT="DisplayXR-Installer-${{ steps.ver.outputs.version }}$SUFFIX.apk"
mkdir -p _out
cp "$SRC" "_out/$OUT"
( cd _out && sha256sum "$OUT" > "$OUT.sha256" )
Expand All @@ -154,9 +240,10 @@ jobs:

# Opt-in, and only onto a release that already exists — the bundle release
# is cut by build-android-bundle.yml, and this attaches to the same tag
# rather than creating a second one.
# rather than creating a second one. Only a release-signed APK gets here: a
# release_tag makes the pin check above required.
- name: Attach to the release
if: inputs.release_tag != ''
if: inputs.release_tag != '' && steps.pin.outputs.release_signed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
Expand All @@ -165,3 +252,7 @@ jobs:
"_out/${{ steps.apk.outputs.name }}" \
"_out/${{ steps.apk.outputs.name }}.sha256" \
--repo "$GITHUB_REPOSITORY" --clobber

- name: Remove the decoded key
if: always()
run: rm -f "$RUNNER_TEMP/dxr-installer-release.p12"
20 changes: 20 additions & 0 deletions .github/workflows/publish-bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -267,9 +267,17 @@ jobs:
# The old with-services (cnsdk) build that embedded them is retired, but the rule
# it forced stays: an installer APK attached to a public release must carry nothing
# embedded, and the release job below re-proves that from the bytes.
#
# It must be signed with THE release key (android-installer/release-signing-cert.sha256):
# one key forever, or tablets cannot update the installer in place. The key is only
# reachable from `main` (environment android-installer-release), so dispatch this
# workflow from main; require_release_key fails the build job otherwise, and the
# release job re-checks the downloaded bytes before anything is published.
build-android-installer:
needs: assert-versions-in-sync
uses: ./.github/workflows/build-android-installer.yml
with:
require_release_key: true

release:
needs: [build-macos, build-windows, build-linux, build-android-installer]
Expand Down Expand Up @@ -305,6 +313,18 @@ jobs:
( cd _out && sha256sum -c "$(basename "$a").sha256" )
echo "OK: $(basename "$a") is the public flavor."

# The other rule this release must never break: the installer is signed with THE
# release key, the one every copy already on a tablet was signed with. An APK signed
# with anything else (a debug key, a new key) cannot update those copies in place, so
# it fails the release here — never on a tablet. Judged from the downloaded bytes,
# independently of the build job's own check.
- name: The Android installer is signed with the release key
run: |
set -euo pipefail
shopt -s nullglob
apks=(_out/DisplayXR-Installer-*.apk)
android-installer/scripts/verify-release-signature.sh "${apks[0]}"

- name: Create release
uses: softprops/action-gh-release@v3
with:
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ _out/
*.exe
!installer/windows/*.exe.placeholder

# Local credentials
# Local credentials — incl. the Android installer's release keystore, which is never committed
.secrets/
*.jks
*.p12
*.keystore
.env
.env.local

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,4 @@ Full design rationale:
- **Don't strip ad-hoc signatures** from macOS dylibs when re-wrapping the runtime `.pkg`. `pkgutil --expand` / `pkgutil --flatten` preserve signatures by default; do NOT add `install_name_tool` or `codesign --remove-signature` to the build flow. Regression of `DisplayXR/displayxr-runtime#279` would SIGKILL the installed runtime at `dlopen`. The build script verifies the runtime payload's presence after re-wrap; extend that check if you change the rewrap flow.
- **Don't auto-fire `publish-bundle.yml`.** It's `workflow_dispatch` for a reason (compat-test gate). The `assert-versions-in-sync` job exists to *fail-fast* on drift, not to repair pins.
- **Don't sign anything.** v1 ships unsigned (#280 / #281 in runtime repo are parallel work). When those land, signing gets a small CI block; until then, no `codesign` / `signtool` calls.
- **Exception: the Android installer APK is release-signed, with ONE key, forever.** `DisplayXR-Installer-<ver>.apk` is signed in CI with the key in the GitHub environment `android-installer-release` (main-only), and every publish path refuses an APK whose signer is not the pin in `android-installer/release-signing-cert.sha256` (`android-installer/scripts/verify-release-signature.sh`). Never "fix" a failing release by editing the pin or falling back to the debug key: a different key cannot update the copies already on tablets. Design, rotation and backup: `android-installer/README.md` § *Signing*.
5 changes: 3 additions & 2 deletions android-bundle/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,9 @@ screen before doing so — launching an app behind the lockscreen crashes some d
The vendor licence notices (the same files as `licenses/`) are published beside the services and
readable in-app: *Licences for the display services*, at the bottom of the screen.

The installer is currently signed with a per-build debug key, so a newer installer cannot update an
older one in place: uninstall the old *installer app* first (this does not touch anything it
From 0.4.2 the installer is signed with one permanent release key, so a newer installer updates an
older one in place. Installers 0.4.1 and older were signed with a per-build debug key: replacing one
of those is a one-time uninstall of the old *installer app* first (this does not touch anything it
installed). The retired `…-with-cnsdk.apk` build is a different app id
(`com.displayxr.installer.cnsdk`); uninstall it too, so there is one installer on the tablet.

Expand Down
Loading
Loading