diff --git a/.gitattributes b/.gitattributes
index 8289a470..11715be6 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,5 +1,7 @@
# Shell scripts: enforce LF (avoid CRLF syntax errors on target)
*.sh text eol=lf
+# Unified-diff context lines intentionally contain a single trailing space.
+*.patch whitespace=-blank-at-eol
*.pdf filter=lfs diff=lfs merge=lfs -text
*.bin filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
index cfd084cf..9a8c29eb 100644
--- a/.github/actionlint.yaml
+++ b/.github/actionlint.yaml
@@ -1,3 +1,4 @@
self-hosted-runner:
labels:
- yocto
+ - ai-tools
diff --git a/.github/workflows/layer-adoption-gate.yml b/.github/workflows/layer-adoption-gate.yml
index 7e875fb9..c5acfaeb 100644
--- a/.github/workflows/layer-adoption-gate.yml
+++ b/.github/workflows/layer-adoption-gate.yml
@@ -3,6 +3,8 @@ name: Layer Adoption Gate
on:
pull_request:
branches: [main, develop]
+ merge_group:
+ types: [checks_requested]
push:
branches: [main, develop]
workflow_dispatch:
@@ -10,10 +12,14 @@ on:
base_sha:
description: Baseline commit to compare
required: true
+ tuple_id:
+ description: One protected tuple to build for focused development
+ required: true
+ default: imx8mm-jaguar-screen-waydroid-image
concurrency:
- group: layer-adoption-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
+ group: layer-adoption-${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name != 'merge_group' }}
defaults:
run:
@@ -22,7 +28,7 @@ defaults:
jobs:
detect:
name: Detect material layer change
- runs-on: [self-hosted, Linux, X64, yocto, ai-tools]
+ runs-on: [self-hosted, Linux, X64, yocto, dd-esl-proxmox]
outputs:
material: ${{ steps.detect.outputs.material }}
base_sha: ${{ steps.base.outputs.sha }}
@@ -35,10 +41,11 @@ jobs:
name: Resolve immutable baseline
env:
PR_BASE: ${{ github.event.pull_request.base.sha }}
+ MERGE_BASE: ${{ github.event.merge_group.base_sha }}
PUSH_BASE: ${{ github.event.before }}
INPUT_BASE: ${{ inputs.base_sha }}
run: |
- sha="${PR_BASE:-${INPUT_BASE:-${PUSH_BASE:-}}}"
+ sha="${PR_BASE:-${MERGE_BASE:-${INPUT_BASE:-${PUSH_BASE:-}}}}"
if [ -z "$sha" ] || printf '%s' "$sha" | grep -Eq '^0+$'; then
sha=$(git rev-parse HEAD^)
fi
@@ -59,10 +66,14 @@ jobs:
--base '${{ steps.base.outputs.sha }}' \
--head '${{ github.sha }}' \
--github-output "$GITHUB_OUTPUT"
- tuple_ids=$(python3 -c 'import json; print(json.dumps([entry["id"] for entry in json.load(open("ci/layer-adoption-tuples.json"))["tuples"]], separators=(",", ":")))')
+ if [ '${{ github.event_name }}' = workflow_dispatch ]; then
+ tuple_ids=$(python3 -c 'import json,sys; ids=[entry["id"] for entry in json.load(open("ci/layer-adoption-tuples.json"))["tuples"]]; requested=sys.argv[1]; requested in ids or sys.exit(f"unknown protected tuple: {requested}"); print(json.dumps([requested],separators=(",",":")))' '${{ inputs.tuple_id }}')
+ else
+ tuple_ids=$(python3 -c 'import json; print(json.dumps([entry["id"] for entry in json.load(open("ci/layer-adoption-tuples.json"))["tuples"]], separators=(",", ":")))')
+ fi
echo "tuple_ids=$tuple_ids" >> "$GITHUB_OUTPUT"
regression:
- name: Existing product regression (${{ matrix.tuple_id }})
+ name: ${{ github.event_name == 'workflow_dispatch' && 'Development validation — baseline comparison' || 'Product readiness validation — baseline comparison' }} (${{ matrix.tuple_id }})
needs: detect
if: needs.detect.outputs.material == 'true'
strategy:
@@ -72,12 +83,12 @@ jobs:
# Each tuple is an independent shard so one failure cannot hide later
# product failures. The final Layer Adoption Gate remains the single
# branch-protection contract, and local driver runs still cover all tuples.
- runs-on: [self-hosted, Linux, X64, yocto, ai-tools]
+ runs-on: [self-hosted, Linux, X64, yocto, dd-esl-proxmox]
container:
image: ghcr.io/siemens/kas/kas@sha256:d989add57fc441fe9e27bb2dd6ed98c5597b44c807928e35a72dc1cfbdda9abe
- # Match the dedicated ai-tools runner account so BitBake's root-user
- # sanity check remains active and bind-mounted cache files stay writable.
- options: --privileged --platform linux/amd64 --user 1002:1002 -v /home/ghrunner/yocto-layer-adoption:/var/cache/layer-adoption
+ # Match the dedicated DD registration on CT101 so BitBake's root-user
+ # sanity check remains active and the /yocto cache stays writable.
+ options: --privileged --platform linux/amd64 --user 999:995 -v /yocto/yocto-layer-adoption:/var/cache/layer-adoption
env:
LAYER_ADOPTION_CACHE: /var/cache/layer-adoption
steps:
@@ -119,7 +130,7 @@ jobs:
candidate/scripts/validation/generate-layer-adoption-test-keys.sh "$temporary"
mv "$temporary" "$keys"
fi
- - name: Build and compare every protected tuple
+ - name: Build and compare selected protected tuple
run: |
python3 candidate/scripts/validation/run-layer-adoption-regression.py \
--baseline baseline \
@@ -151,10 +162,10 @@ jobs:
done
required:
- name: Layer Adoption Gate
+ name: ${{ github.event_name == 'workflow_dispatch' && 'Development Validation' || 'Layer Adoption Gate' }}
needs: [detect, regression]
if: always()
- runs-on: [self-hosted, Linux, X64, yocto, ai-tools]
+ runs-on: [self-hosted, Linux, X64, yocto, dd-esl-proxmox]
steps:
- name: Enforce gate result
env:
diff --git a/.gitignore b/.gitignore
index fe244302..a9d3a790 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# Build outputs and layers (these are downloaded by KAS)
build/
+**/out/
tmp/
tmp-glibc/
cache/
diff --git a/ci/layer-adoption-contract.json b/ci/layer-adoption-contract.json
index 8496f62f..9d0ccff5 100644
--- a/ci/layer-adoption-contract.json
+++ b/ci/layer-adoption-contract.json
@@ -1,6 +1,6 @@
{
"schema": 1,
- "reason": "Adopt the isolated NXP i.MX95 partner layer without changing any pre-existing Dynamic Devices build tuple.",
+ "reason": "Adopt the exact-pinned Scarthgap meta-selinux layer and enable enforcing host SELinux only for product-feature Android containers, while preserving every existing Foundries platform, signing, recovery and manufacturing tuple.",
"baseline_repairs": [
{
"base_sha": "dda54409ee27e29612c01cc1ff0eb88233ca1da5",
@@ -19,7 +19,7 @@
"recipes-bsp/upd72020x-load/upd72020x-load/LicenseRef-markusj-upd72020x-load",
"recipes-bsp/upd72020x-load/upd72020x-load_git.bb"
],
- "reason": "The immutable baseline has two stale U-Boot patch contexts, mishandles intentionally empty board-scripts packages, carries three product DTS files that no longer compile against the pinned kernel, and does not expose the Phasora loader's custom license text to SPDX generation. Build both sides with the focused eight-file backport while auditing the exact old-to-new submodule transition."
+ "reason": "The immutable mainline baseline has two stale U-Boot patch contexts, mishandles intentionally empty board-scripts packages, carries three product DTS files that no longer compile against the pinned kernel, and does not expose the Phasora loader's custom license text to SPDX generation. Build both sides with the focused eight-file backport; the candidate BSP must contain this exact repair commit before its product-specific changes."
}
],
"allowed_deltas": {}
diff --git a/ci/layer-adoption-tuples.json b/ci/layer-adoption-tuples.json
index 280468ab..df686998 100644
--- a/ci/layer-adoption-tuples.json
+++ b/ci/layer-adoption-tuples.json
@@ -1,7 +1,13 @@
{
"schema": 1,
+ "source": {
+ "repository": "https://source.foundries.io/factories/dynamic-devices/ci-scripts.git",
+ "commit": "520e5c11dede126bd1bd35184293c20addeda380",
+ "file": "factory-config.yml"
+ },
"tuples": [
- {"id": "imx8mm-jaguar-screen-image", "machine": "imx8mm-jaguar-screen", "distro": "lmp-dynamicdevices", "image": "lmp-factory-image", "config": "kas/lmp-dynamicdevices.yml", "product_features": "display flutter godot"},
- {"id": "imx8mm-jaguar-screen-mfgtool", "machine": "imx8mm-jaguar-screen", "distro": "lmp-mfgtool", "image": "mfgtool-files", "config": "kas/lmp-dynamicdevices-mfgtool.yml", "product_features": ""}
+ {"id":"imx8mm-jaguar-screen-image","machine":"imx8mm-jaguar-screen","distro":"lmp-dynamicdevices","image":"lmp-factory-image","config":"kas/lmp-dynamicdevices.yml","product_features":"display flutter godot","variables":{}},
+ {"id":"imx8mm-jaguar-screen-mfgtool","machine":"imx8mm-jaguar-screen","distro":"lmp-mfgtool","image":"mfgtool-files","config":"kas/lmp-dynamicdevices-mfgtool.yml","product_features":"","variables":{}},
+ {"id":"imx8mm-jaguar-screen-waydroid-image","machine":"imx8mm-jaguar-screen","distro":"lmp-dynamicdevices","image":"lmp-factory-image","config":"kas/lmp-dynamicdevices.yml","product_features":"display android-container","variables":{"ACCEPT_FSL_EULA":"1","ASSEMBLE_SYSTEM_IMAGE":"0","DEV_MODE":"1","DOCKER_COMPOSE_APP":"1","PATCHTOOL":"git","WAYDROID_SELINUX_DEVELOPMENT_PERMISSIVE":"1","WAYDROID_SELINUX_POLICY_DISCOVERY":"1"}}
]
}
diff --git a/conf/layer.conf b/conf/layer.conf
index d50bafef..86e51565 100644
--- a/conf/layer.conf
+++ b/conf/layer.conf
@@ -24,6 +24,7 @@ BBFILES_DYNAMIC += " \
rust-bin-layer:${LAYERDIR}/bbappends/meta-rust-bin/*/*/*.bb rust-bin-layer:${LAYERDIR}/bbappends/meta-rust-bin/*/*/*.bbappend \
meta-tensorflow:${LAYERDIR}/bbappends/meta-tensorflow/*/*/*.bbappend \
nxp-zigbee-rcp:${LAYERDIR}/bbappends/meta-nxp-zigbee-rcp/*/*/*.bbappend \
+ selinux:${LAYERDIR}/dynamic-layers/selinux/recipes-*/*/*.bbappend \
"
LAYERDEPENDS_meta-dynamicdevices = "meta-lmp-base meta-dynamicdevices-bsp meta-dynamicdevices-distro"
diff --git a/demos/jaguar-waydroid-gpu-demo/README.md b/demos/jaguar-waydroid-gpu-demo/README.md
new file mode 100644
index 00000000..46d09378
--- /dev/null
+++ b/demos/jaguar-waydroid-gpu-demo/README.md
@@ -0,0 +1,45 @@
+# Jaguar Waydroid GPU demo
+
+An offline OpenGL ES 2.0 starfield benchmark for the Jaguar Screen Waydroid
+image. It displays the renderer reported by Android, live FPS, and particle
+count. Tap the screen to cycle through 2,000, 10,000, 40,000, and 80,000
+particles.
+
+## Build
+
+```sh
+./build.sh
+```
+
+The build uses the latest Android SDK platform and build-tools installed under
+`ANDROID_SDK_ROOT` or `~/Android/Sdk`. It compiles directly with the SDK
+tools, so Gradle and network access are not required. The result is
+`out/jaguar-gpu-demo.apk`, signed with the standard local Android debug key.
+
+## Install on Jaguar Screen
+
+Run the Waydroid commands as the same `weston` user that owns the graphical
+session:
+
+```sh
+sudo -u weston env \
+ HOME=/var/rootdirs/home/weston \
+ XDG_RUNTIME_DIR=/run/user/63 \
+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/63/bus \
+ WAYLAND_DISPLAY=wayland-1 \
+ waydroid app install /tmp/jaguar-gpu-demo.apk
+
+sudo -u weston env \
+ HOME=/var/rootdirs/home/weston \
+ XDG_RUNTIME_DIR=/run/user/63 \
+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/63/bus \
+ WAYLAND_DISPLAY=wayland-1 \
+ waydroid app launch com.dynamicdevices.jaguargpu
+```
+
+The v1.0.0 bench baseline rendered 10,000 particles at approximately 13 fps at
+the panel's full 1920x1200 logical resolution. The overlay identified
+`Vivante GC600 rev 4653` and `OpenGL ES 2.0`.
+
+The APK uses only Android platform APIs and has no network permission or
+external runtime dependency.
diff --git a/demos/jaguar-waydroid-gpu-demo/app/src/main/AndroidManifest.xml b/demos/jaguar-waydroid-gpu-demo/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..6231722f
--- /dev/null
+++ b/demos/jaguar-waydroid-gpu-demo/app/src/main/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/jaguar-waydroid-gpu-demo/app/src/main/java/com/dynamicdevices/jaguargpu/MainActivity.java b/demos/jaguar-waydroid-gpu-demo/app/src/main/java/com/dynamicdevices/jaguargpu/MainActivity.java
new file mode 100644
index 00000000..b1c3a579
--- /dev/null
+++ b/demos/jaguar-waydroid-gpu-demo/app/src/main/java/com/dynamicdevices/jaguargpu/MainActivity.java
@@ -0,0 +1,250 @@
+// SPDX-License-Identifier: GPL-3.0-only
+package com.dynamicdevices.jaguargpu;
+
+import android.app.Activity;
+import android.graphics.Color;
+import android.opengl.GLES20;
+import android.opengl.GLSurfaceView;
+import android.os.Bundle;
+import android.view.Gravity;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.FloatBuffer;
+import java.util.Locale;
+import java.util.Random;
+
+import javax.microedition.khronos.egl.EGLConfig;
+import javax.microedition.khronos.opengles.GL10;
+
+public final class MainActivity extends Activity {
+ private BenchmarkRenderer renderer;
+ private TextView stats;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ getWindow().getDecorView().setSystemUiVisibility(
+ View.SYSTEM_UI_FLAG_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
+
+ FrameLayout root = new FrameLayout(this);
+ GLSurfaceView surface = new GLSurfaceView(this);
+ surface.setEGLContextClientVersion(2);
+ surface.setPreserveEGLContextOnPause(true);
+ renderer = new BenchmarkRenderer(new StatsSink() {
+ @Override
+ public void update(String value) {
+ showStats(value);
+ }
+ });
+ surface.setRenderer(renderer);
+ surface.setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
+ root.addView(surface, new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT));
+
+ stats = new TextView(this);
+ stats.setTextColor(Color.WHITE);
+ stats.setTextSize(20);
+ stats.setPadding(24, 16, 24, 16);
+ stats.setGravity(Gravity.START);
+ stats.setBackgroundColor(0x99030a18);
+ stats.setText("JAGUAR GPU DRIVE\nStarting GLES 2.0…");
+ FrameLayout.LayoutParams overlay = new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ Gravity.TOP | Gravity.START);
+ overlay.setMargins(24, 24, 0, 0);
+ root.addView(stats, overlay);
+
+ TextView hint = new TextView(this);
+ hint.setTextColor(0xff79f7ff);
+ hint.setTextSize(18);
+ hint.setPadding(18, 10, 18, 10);
+ hint.setBackgroundColor(0x99030a18);
+ hint.setText("TAP TO CHANGE LOAD");
+ FrameLayout.LayoutParams hintParams = new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL);
+ hintParams.setMargins(0, 0, 0, 24);
+ root.addView(hint, hintParams);
+
+ View.OnClickListener cycleLoad = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ renderer.cycleLoad();
+ }
+ };
+ root.setOnClickListener(cycleLoad);
+ surface.setOnClickListener(cycleLoad);
+ hint.setOnClickListener(cycleLoad);
+ setContentView(root);
+ }
+
+ private void showStats(String value) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ stats.setText(value);
+ }
+ });
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ getWindow().getDecorView().setSystemUiVisibility(
+ View.SYSTEM_UI_FLAG_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
+ }
+
+ private static final class BenchmarkRenderer implements GLSurfaceView.Renderer {
+ private static final int[] LOADS = {2000, 10000, 40000, 80000};
+ private static final String[] LOAD_NAMES = {"CRUISE", "FAST", "TURBO", "MAX"};
+ private static final int MAX_PARTICLES = LOADS[LOADS.length - 1];
+ private static final String VERTEX_SHADER =
+ "attribute vec4 aParticle;\n"
+ + "uniform float uTime;\n"
+ + "varying float vGlow;\n"
+ + "varying vec3 vColour;\n"
+ + "void main() {\n"
+ + " float z = fract(aParticle.z - uTime * aParticle.w);\n"
+ + " float depth = 0.08 + z;\n"
+ + " float twist = uTime * 0.20 + (1.0-z) * 2.4;\n"
+ + " mat2 r = mat2(cos(twist), -sin(twist), sin(twist), cos(twist));\n"
+ + " vec2 p = r * aParticle.xy / depth;\n"
+ + " gl_Position = vec4(p * 0.72, 0.0, 1.0);\n"
+ + " gl_PointSize = 1.2 + (1.0-z) * 8.0;\n"
+ + " vGlow = (1.0-z) * smoothstep(1.3, 0.1, length(p));\n"
+ + " vColour = mix(vec3(0.10,0.45,1.0), vec3(0.15,1.0,0.82), aParticle.w*18.0);\n"
+ + "}\n";
+ private static final String FRAGMENT_SHADER =
+ "precision mediump float;\n"
+ + "varying float vGlow;\n"
+ + "varying vec3 vColour;\n"
+ + "void main() {\n"
+ + " vec2 q = gl_PointCoord - vec2(0.5);\n"
+ + " float d = length(q);\n"
+ + " float core = smoothstep(0.50, 0.02, d);\n"
+ + " float halo = smoothstep(0.50, 0.18, d);\n"
+ + " gl_FragColor = vec4(vColour * (core + halo*0.7) * (0.3+vGlow*1.8), core*vGlow);\n"
+ + "}\n";
+
+ private final StatsSink sink;
+ private final FloatBuffer particles;
+ private int program;
+ private int positionHandle;
+ private int timeHandle;
+ private int loadIndex = 1;
+ private long startNanos;
+ private long sampleNanos;
+ private int sampleFrames;
+ private String rendererName = "detecting GPU";
+ private volatile boolean loadChanged;
+
+ BenchmarkRenderer(StatsSink sink) {
+ this.sink = sink;
+ float[] data = new float[MAX_PARTICLES * 4];
+ Random random = new Random(0x475055);
+ for (int i = 0; i < MAX_PARTICLES; i++) {
+ double angle = random.nextDouble() * Math.PI * 2.0;
+ double radius = Math.sqrt(random.nextDouble()) * 0.92;
+ data[i * 4] = (float) (Math.cos(angle) * radius);
+ data[i * 4 + 1] = (float) (Math.sin(angle) * radius);
+ data[i * 4 + 2] = random.nextFloat();
+ data[i * 4 + 3] = 0.010f + random.nextFloat() * 0.045f;
+ }
+ particles = ByteBuffer.allocateDirect(data.length * 4)
+ .order(ByteOrder.nativeOrder()).asFloatBuffer();
+ particles.put(data).position(0);
+ }
+
+ @Override
+ public void onSurfaceCreated(GL10 ignored, EGLConfig config) {
+ program = link(VERTEX_SHADER, FRAGMENT_SHADER);
+ positionHandle = GLES20.glGetAttribLocation(program, "aParticle");
+ timeHandle = GLES20.glGetUniformLocation(program, "uTime");
+ GLES20.glEnable(GLES20.GL_BLEND);
+ GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE);
+ GLES20.glClearColor(0.005f, 0.012f, 0.045f, 1.0f);
+ rendererName = GLES20.glGetString(GLES20.GL_RENDERER);
+ startNanos = sampleNanos = System.nanoTime();
+ publish(0.0);
+ }
+
+ @Override
+ public void onSurfaceChanged(GL10 ignored, int width, int height) {
+ GLES20.glViewport(0, 0, width, height);
+ }
+
+ @Override
+ public void onDrawFrame(GL10 ignored) {
+ long now = System.nanoTime();
+ GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
+ GLES20.glUseProgram(program);
+ GLES20.glUniform1f(timeHandle, (now - startNanos) / 1_000_000_000.0f);
+ particles.position(0);
+ GLES20.glVertexAttribPointer(positionHandle, 4, GLES20.GL_FLOAT, false, 16, particles);
+ GLES20.glEnableVertexAttribArray(positionHandle);
+ GLES20.glDrawArrays(GLES20.GL_POINTS, 0, LOADS[loadIndex]);
+
+ sampleFrames++;
+ if (loadChanged || now - sampleNanos >= 1_000_000_000L) {
+ double fps = sampleFrames * 1_000_000_000.0 / (now - sampleNanos);
+ publish(fps);
+ sampleFrames = 0;
+ sampleNanos = now;
+ loadChanged = false;
+ }
+ }
+
+ void cycleLoad() {
+ loadIndex = (loadIndex + 1) % LOADS.length;
+ loadChanged = true;
+ }
+
+ private void publish(double fps) {
+ sink.update(String.format(Locale.US,
+ "JAGUAR GPU DRIVE • %s\n%.1f FPS • %,d particles\n%s • OpenGL ES 2.0",
+ LOAD_NAMES[loadIndex], fps, LOADS[loadIndex], rendererName));
+ }
+
+ private static int link(String vertexSource, String fragmentSource) {
+ int vertex = compile(GLES20.GL_VERTEX_SHADER, vertexSource);
+ int fragment = compile(GLES20.GL_FRAGMENT_SHADER, fragmentSource);
+ int result = GLES20.glCreateProgram();
+ GLES20.glAttachShader(result, vertex);
+ GLES20.glAttachShader(result, fragment);
+ GLES20.glLinkProgram(result);
+ int[] ok = new int[1];
+ GLES20.glGetProgramiv(result, GLES20.GL_LINK_STATUS, ok, 0);
+ if (ok[0] == 0) {
+ throw new IllegalStateException(GLES20.glGetProgramInfoLog(result));
+ }
+ return result;
+ }
+
+ private static int compile(int type, String source) {
+ int shader = GLES20.glCreateShader(type);
+ GLES20.glShaderSource(shader, source);
+ GLES20.glCompileShader(shader);
+ int[] ok = new int[1];
+ GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, ok, 0);
+ if (ok[0] == 0) {
+ throw new IllegalStateException(GLES20.glGetShaderInfoLog(shader));
+ }
+ return shader;
+ }
+ }
+
+ private interface StatsSink {
+ void update(String value);
+ }
+}
diff --git a/demos/jaguar-waydroid-gpu-demo/build.sh b/demos/jaguar-waydroid-gpu-demo/build.sh
new file mode 100755
index 00000000..a855001b
--- /dev/null
+++ b/demos/jaguar-waydroid-gpu-demo/build.sh
@@ -0,0 +1,55 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-only
+set -eu
+
+SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
+cd "$SCRIPT_DIR"
+
+SDK_ROOT="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
+BUILD_TOOLS="${BUILD_TOOLS:-$(find "$SDK_ROOT/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1)}"
+PLATFORM="${ANDROID_PLATFORM:-$(find "$SDK_ROOT/platforms" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1)}"
+ANDROID_JAR="$PLATFORM/android.jar"
+OUT="$SCRIPT_DIR/out"
+
+mkdir -p "$OUT"
+find "$OUT" -mindepth 1 -delete
+mkdir -p "$OUT/classes" "$OUT/dex"
+
+javac -source 8 -target 8 -Xlint:-options \
+ -bootclasspath "$ANDROID_JAR" \
+ -d "$OUT/classes" \
+ app/src/main/java/com/dynamicdevices/jaguargpu/MainActivity.java
+
+jar --create --file "$OUT/classes.jar" -C "$OUT/classes" .
+"$BUILD_TOOLS/d8" \
+ --lib "$ANDROID_JAR" \
+ --min-api 29 \
+ --output "$OUT/dex" \
+ "$OUT/classes.jar"
+
+"$BUILD_TOOLS/aapt2" link \
+ -I "$ANDROID_JAR" \
+ --manifest app/src/main/AndroidManifest.xml \
+ --min-sdk-version 29 \
+ --target-sdk-version 35 \
+ -o "$OUT/jaguar-gpu-demo-unsigned.apk"
+
+cd "$OUT/dex"
+zip -q -u "$OUT/jaguar-gpu-demo-unsigned.apk" classes.dex
+cd - >/dev/null
+
+KEYSTORE="$HOME/.android/debug.keystore"
+if [ ! -f "$KEYSTORE" ]; then
+ mkdir -p "$(dirname "$KEYSTORE")"
+ keytool -genkeypair -keystore "$KEYSTORE" -storepass android -keypass android \
+ -alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
+ -dname "CN=Android Debug,O=Android,C=US" >/dev/null 2>&1
+fi
+
+"$BUILD_TOOLS/zipalign" -f 4 \
+ "$OUT/jaguar-gpu-demo-unsigned.apk" "$OUT/jaguar-gpu-demo-aligned.apk"
+"$BUILD_TOOLS/apksigner" sign \
+ --ks "$KEYSTORE" --ks-pass pass:android --key-pass pass:android \
+ --out "$OUT/jaguar-gpu-demo.apk" "$OUT/jaguar-gpu-demo-aligned.apk"
+"$BUILD_TOOLS/apksigner" verify --verbose "$OUT/jaguar-gpu-demo.apk"
+printf 'Built %s\n' "$OUT/jaguar-gpu-demo.apk"
diff --git a/docs/JAGUAR-SCREEN-WAYDROID.md b/docs/JAGUAR-SCREEN-WAYDROID.md
new file mode 100644
index 00000000..721498a9
--- /dev/null
+++ b/docs/JAGUAR-SCREEN-WAYDROID.md
@@ -0,0 +1,98 @@
+# Jaguar Screen Waydroid
+
+## Released baseline
+
+Jaguar Screen Waydroid v1.0.0 boots the physical
+`imx8mm-jaguar-screen` board from a Foundries-built LmP image into a
+full-screen LineageOS desktop. The first proven image was Foundries target
+2887, built from manifest `88ab13ce2c5f611847566be3d1b8f9f4b4ca47cf`.
+Target 2888 was rejected during physical validation: its SPL watchdog change
+did not remove the reboot delay and affected the proven boot presentation.
+
+The validated display path is:
+
+```text
+LineageOS SurfaceFlinger
+ -> Waydroid minigbm / Mesa
+ -> etnaviv render node
+ -> Weston on card2
+ -> DRM DSI-1
+ -> ST1010B3CYOL / HX8279-D panel at 1920x1200 logical
+```
+
+SurfaceFlinger reported `Mesa, Vivante GC600 rev 4653, OpenGL ES 2.0 Mesa
+26.0.1`. Weston used `renderD128` and the same GC600 renderer. This proves
+hardware rendering on both sides of the Waydroid/Wayland boundary.
+
+## Boot contract
+
+The machine enables these services:
+
+1. `waydroid-image-provision.service` checks persistent
+ `/var/lib/waydroid/images` and runs `waydroid init` only when either
+ image is missing.
+2. `waydroid-jaguar-container.service` owns the long-running container.
+3. `waydroid-jaguar-session.service` runs as the fixed `weston` user and
+ binds the Android session to Weston.
+4. `waydroid-jaguar-ui.service` waits for the session and opens the
+ full-screen Android UI.
+
+Weston is pinned to DRM `card2`; `card0` is the firmware framebuffer and
+`card1` is the render-only etnaviv node. The physical panel scans out at
+1200x1920 and Weston applies `transform=rotate-90` to expose a 1920x1200
+landscape desktop. Its handover background must therefore use
+`active-edge-splash-1920x1200.png`.
+
+Android images are deliberately stored outside OSTree in
+`/var/lib/waydroid/images`. A Foundries OS update changes the host and
+services without replacing an already-provisioned Android image. Delete or
+replace those images only as a deliberate image-management operation.
+
+## Host requirements
+
+The release requires:
+
+- Binder and Android host kernel support;
+- pressure stall information for Android `lmkd`;
+- etnaviv DRM and the Vivante GC600 device-tree nodes;
+- Wayland, OpenGL and the Waydroid distro feature;
+- persistent storage for the Android system and vendor images.
+
+The Screen machine accepts etnaviv/OpenGL without requiring Vulkan because the
+GC600 exposes OpenGL ES 2.0.
+
+## Operations
+
+Check the complete stack:
+
+```sh
+systemctl is-active \
+ weston.service \
+ waydroid-jaguar-container.service \
+ waydroid-jaguar-session.service \
+ waydroid-jaguar-ui.service
+waydroid status
+```
+
+Run `waydroid app install` and `waydroid app launch` as the `weston`
+session user. The exact environment is shown in
+`demos/jaguar-waydroid-gpu-demo/README.md`.
+
+The Screen U-Boot configuration disables `CONFIG_WATCHDOG_AUTOSTART`. Do not
+change the SPL watchdog configuration as a reboot workaround: target 2888
+proved that doing so did not remove the delay and disturbed the display
+baseline. The Screen-specific systemd manager drop-in sets
+`RebootWatchdogSec=2s`. It bounds the final post-sync stall without changing
+U-Boot, the kernel, DRM, or the splash handoff.
+
+## Demonstration
+
+`demos/jaguar-waydroid-gpu-demo` is the release demonstration and a bounded
+graphics load. It is an offline GLES 2 star tunnel with live FPS, renderer
+identity and tap-selectable particle counts. It avoids browser GPU policy and
+network availability, making it suitable for repeatable bench and visitor
+demos.
+
+The first physical run at 10,000 particles reported approximately 13 fps and
+identified `Vivante GC600 rev 4653`. Use the lower setting for a smooth
+visual introduction and increase the particle count to demonstrate scaling.
diff --git a/docs/PRODUCT_TUPLE_LIFECYCLE.md b/docs/PRODUCT_TUPLE_LIFECYCLE.md
index cbb2a3f6..9956205c 100644
--- a/docs/PRODUCT_TUPLE_LIFECYCLE.md
+++ b/docs/PRODUCT_TUPLE_LIFECYCLE.md
@@ -20,8 +20,9 @@ image and recovery coverage.
## Temporarily deferred CI coverage
For the current R26 screen-board integration phase, the layer-adoption workflow
-runs only the `imx8mm-jaguar-screen` factory-image and mfgtool/recovery tuples.
-Other still-active product families are deferred to a later CI expansion; they
-are not deprecated, and this focused run must not be cited as proof that the
-full historical product matrix passed. Production-wide adoption remains gated
-on restoring and passing that broader coverage.
+runs the existing `imx8mm-jaguar-screen` factory-image and mfgtool/recovery
+tuples, then adds the exact Foundries `main-jaguar-screen` Android-container
+tuple. Other still-active product families are deferred to a later CI
+expansion; they are not deprecated, and this focused run must not be cited as
+proof that the full historical product matrix passed. Production-wide adoption
+remains gated on restoring and passing that broader coverage.
diff --git a/docs/README.md b/docs/README.md
index 2f6916d4..3677709e 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -14,6 +14,11 @@ This folder contains formal documentation, reports, and reference materials for
**Audience**: Project managers, technical leads
**Contents**: Build-specific reports, testing checklists, issues tracking, next steps
+### `/releases/`
+**Purpose**: Tested product baselines tied to immutable source and Foundries targets
+**Audience**: Engineers, release reviewers, demonstration teams
+**Contents**: Release scope, evidence, known limits, and exact validation build
+
### `/investigations/`
**Purpose**: Technical investigation reports and research findings
**Audience**: Engineers, technical team
@@ -36,6 +41,11 @@ This folder contains formal documentation, reports, and reference materials for
## Engineering Documentation
+- [Jaguar Screen Waydroid](JAGUAR-SCREEN-WAYDROID.md) — released Android
+ container, graphics, boot and operations baseline.
+- [Jaguar Screen Waydroid v1.0.0](releases/JAGUAR-SCREEN-WAYDROID-v1.0.0.md)
+ — release evidence and known limits.
+
**For day-to-day engineering documentation, guides, and tutorials, see the `wiki/` folder.**
The wiki contains:
diff --git a/docs/r26-waydroid-selinux-plan.md b/docs/r26-waydroid-selinux-plan.md
new file mode 100644
index 00000000..274f0f41
--- /dev/null
+++ b/docs/r26-waydroid-selinux-plan.md
@@ -0,0 +1,186 @@
+# R26 Waydroid host SELinux adoption and verification
+
+Status: implementation in progress; not release-approved.
+
+## Objective
+
+Run Waydroid on the 2 GB i.MX8MM Jaguar Screen with the Foundries/LmP host
+SELinux policy enforcing. Android retains only the changes needed to operate
+inside LXC because SELinux is kernel-global and cannot be independently owned
+by the Android userspace in that container.
+
+## Reproducible baseline
+
+- Published Foundries target: `2892` (`main-jaguar-screen`)
+- Manifest: `361d5ac577d9a9714069f689f4ed04680834f1e3`
+- Application layer: `306e43cd04a93f42c0bd195601fa261045da39f0`
+- BSP layer: `c6be3be6d94a5492b534b173f44e65592b49a13e`
+- Distro layer: `c635979548a155f6035325b328ab9bd7a7a2ce24`
+- LmP/meta-lmp: `d176612d7fc811bb8f511fcaa06dc617513c0eb5`
+- meta-selinux candidate: `48f745109a1ecea9afe2a74d41dbd90fa7b370af`
+- Android container-awareness change: `a9c4291`
+- Android validation run: `34748284172`
+
+## Layer-adoption audit
+
+The candidate meta-selinux revision declares Scarthgap compatibility and
+depends on OE-Core plus meta-python; the existing manifest already supplies
+meta-oe. It is inert unless `selinux` is present in `DISTRO_FEATURES`.
+
+The layer changes recipes globally once enabled, including systemd, D-Bus,
+OpenSSH, sudo, util-linux and Mesa. The upstream reference policy explicitly
+requires product tailoring. Its kernel append only targets `linux-yocto`, so
+the Jaguar `linux-lmp-fslc-imx` kernel requires its own fragment.
+
+Adoption is scoped through the product contract. `android-container` implies
+the standard `selinux` distro feature; an explicit `host-selinux` product
+feature is also available for future non-Android products. mfgtool tuples do
+not select either feature.
+
+The custom refpolicy append is a dynamic append: it is parsed only when the
+`selinux` layer collection is present. `APPS_MODS=waydroid` is required because
+new refpolicy modules otherwise default to `off` when absent from the upstream
+`modules.conf`.
+
+## Development policy lifecycle
+
+The first hardware image keeps the host globally enforcing but explicitly sets
+`WAYDROID_SELINUX_DEVELOPMENT_PERMISSIVE = "1"` to declare only `waydroid_t`
+permissive. The recipe rejects that setting unless
+`WAYDROID_SELINUX_POLICY_DISCOVERY = "1"`; all other builds compile the
+Waydroid domain enforcing by default. The dedicated gate deliberately avoids
+`LOCAL_DEVELOPMENT_BUILD`, which also changes SE05x and other platform content,
+so a Foundries discovery OTA can retain the production-shaped secure-boot and
+hardware configuration. This isolates policy discovery to the Waydroid domain,
+avoids weakening unrelated host services, and prevents the discovery setting
+from leaking into a release build. Hardware AVCs must still be classified and
+converted to narrow rules before release.
+
+Immutable OSTree content is labelled at image construction with
+`selinux-image`. `FIRST_BOOT_RELABEL` remains disabled because a whole-root
+relabel is unsuitable for an immutable deployment. The provisioning unit runs
+`restorecon -RF /var/lib/waydroid` to migrate persistent state retained across
+OTA; the existing network script restores the `/run/waydroid-lxc` context.
+
+Device nodes shared with host services are not relabelled as Waydroid-owned.
+Access to Binder, DRM/Etnaviv, V4L2 and the Weston/PulseAudio sockets will be
+granted against their host-owned types from reviewed hardware AVC evidence.
+Android system/vendor files carry Android policy xattrs that are not valid
+host-policy types, so Waydroid applies the single host-owned
+`waydroid_rootfs_t` mount context to its ext4 and overlay mounts whenever host
+SELinux is active. This avoids any release rule permitting execution from the
+generic `unlabeled_t` type.
+
+## Phased CI tuple regression matrix
+
+To reach physical Jaguar Screen testing without waiting for every historical
+product build, the current gate is intentionally focused on three tuples: the
+existing Jaguar Screen image, its mfgtool/recovery image, and the exact
+Foundries `main-jaguar-screen` Android-container image. The remaining active
+platform inventory below is deferred to a later CI expansion and is not
+claimed as passed by this phase.
+
+### Explicitly deprecated tuples
+
+On 13 September 2026 the product owner retired the
+`imx8mm-jaguar-inst` and `imx8mm-jaguar-phasora` build families. This includes
+normal images and mfgtool/recovery builds, plus both the
+`main-jaguar-phasora` and `main-jaguar-phasora-ext` Foundries refs. They are
+therefore intentionally excluded from the protected regression matrix and will
+be removed from the Foundries factory build configuration. Their removal is a
+reviewed product-lifecycle decision, not an unexplained loss of gate coverage.
+
+### Deferred active Foundries inventory
+
+| Platform ref | Machine | Platform distro/special case | Existing mfgtool tuple |
+| --- | --- | --- | --- |
+| `main-jaguar` | `imx8mm-jaguar-sentai` | default | yes |
+| `main-jaguar-sentai` | `imx8mm-jaguar-sentai` | headless, signed/LUKS/OCF parameters | yes |
+| `main-jaguar-sentai-prod` | `imx8mm-jaguar-sentai` | production | yes |
+| `main-jaguar-sentai-ext` | `imx8mm-jaguar-sentai` | default | yes |
+| `main-jaguar-sentai-ce` | `imx8mm-jaguar-sentai` | CE image | yes |
+| `main-jaguar-sentai-ocf` | `imx8mm-jaguar-sentai` | headless, signed/LUKS/OCF | yes |
+| `imx8mm-jaguar-handheld-5in` | `imx8mm-jaguar-handheld-5in` | legacy Waydroid distro | yes |
+| `imx8mm-jaguar-handheld-7in` | `imx8mm-jaguar-handheld-7in` | legacy Waydroid distro | yes |
+| `main-imx8ulp` | `imx8ulp-lpddr4-evk` | headless | yes |
+| `main-rpi4` | `raspberrypi4-64` | default | no |
+| `main-rpi4-v2g-evse` | `raspberrypi4-64` | legacy Waydroid distro | no |
+| `main-rpi5` | `raspberrypi5` | default | no |
+| `main-imx93-jaguar-eink` | `imx93-jaguar-eink` | headless, signed/LUKS | yes |
+| `main-imx95-frdm-devel` | `imx95-frdm-evk` | product-feature Android | yes |
+| `main-jaguar-screen` | `imx8mm-jaguar-screen` | `display android-container` | none currently defined |
+
+## Gates before manifest publication
+
+### Completed exact-pin preflight evidence
+
+- `kas checkout kas/r26-jaguar-screen-selinux.yml` resolved all candidate
+ layers at their recorded SHAs and parsed 4,172 recipes with zero errors.
+- `bitbake -g refpolicy-targeted` proves the policy recipe has no target
+ compiler or libc dependency; it uses only its declared host-native policy
+ tools (`INHIBIT_DEFAULT_DEPS = "1"`).
+- `bitbake refpolicy-targeted -c compile` completed all 459 tasks. The generated
+ `policy/modules.conf` contains `waydroid = module` and the build produced
+ `waydroid.pp` (114,378 bytes), proving the custom module is compiled rather
+ than merely present in `SRC_URI`.
+- The development policy build completed with only `waydroid_t` permissive;
+ the enforcing-smoke build also completed and its generated source contains
+ no permissive declaration. A permissive request in a non-development build
+ is rejected during parsing.
+- The real `virtual/kernel:do_kernel_configcheck` completed all 873 tasks after
+ the local preflight correctly removed the disabled `modsign` distro feature.
+ This proves the Jaguar kernel accepts the SELinux configuration fragment;
+ the earlier dry run and invalid dummy-certificate attempt are not counted.
+- An isolated `repo` sync of the prepared Foundries manifest resolved every
+ pinned project, including the authenticated `main-jaguar-screen` subscriber
+ override. Its production-shaped parse completed 3,954 recipes and 6,137
+ targets with zero errors, and its release-default policy compile completed
+ 459/459 tasks with no permissive Waydroid declaration.
+- The exact image inherits `create-spdx`, `license_image` and `buildhistory`,
+ uses a fixed reproducible rootfs timestamp, and selects `cra-audit`. The
+ SELinux Jaguar Screen image now resolves `audit`, `cra-audit-system`,
+ `logrotate`, `rsyslog` and `systemd-analyze`; a separate parse proves those
+ additions do not leak into the non-SELinux screen baseline.
+
+The focused adoption matrix, full image, Foundries build and physical-board
+gates remain open. Deferred product tuples must be restored before a
+production-wide layer-adoption claim.
+
+1. Resolve the exact candidate manifest and prove every project SHA exists.
+2. Parse the Jaguar Screen platform and mfgtool configurations.
+3. Run `bitbake-layers show-layers`, `show-appends`, and provider checks.
+4. Run kernel `do_kernel_configcheck`; prove SELinux is built in and appears
+ in `CONFIG_LSM` only for the selected product.
+5. Build the policy and rootfs locally; prove the Waydroid module is active,
+ `/etc/selinux/config` says enforcing, and OSTree preserves labels.
+6. Parse/build the full platform and mfgtool regression matrix above, including
+ bootloader, kernel, OP-TEE, signing and recovery/factory artifacts.
+7. Verify SBOM, licence manifest, source hashes and image hashes are retained.
+8. Only then pin the three Dynamic Devices layer commits in the manifest and
+ submit the Foundries build.
+
+## Hardware acceptance
+
+Run the read-only evidence collector after the development OTA, using a small
+raw Annex-B H.264 sample so hardware decode is exercised rather than inferred:
+
+```sh
+sudo scripts/validation/capture-r26-waydroid-board-evidence.sh \
+ /var/tmp/r26-waydroid-development --h264 /var/tmp/r26-test.h264
+```
+
+After AVC classification and the enforcing policy rebuild, repeat with
+`--release`. A non-zero result or any `NOT_RUN` acceptance item is not release
+evidence. Preserve the resulting directory with the Foundries target number,
+manifest SHA, OTA install and rollback logs; the collector itself is
+read-only and does not perform an update or rollback.
+
+- `getenforce` reports `Enforcing`; kernel command line does not disable it.
+- Waydroid processes enter `waydroid_t`; no Waydroid process remains
+ `unconfined_t`, `init_t`, or another generic domain.
+- The release policy contains no permissive domains and no unexplained AVCs.
+- Android boots, Binder works, kiosk UI renders, and 2 GB zram/low-memory
+ settings are active.
+- Etnaviv acceleration and V4L2 H.264 decode are demonstrated independently.
+- Foundries OTA install and rollback both succeed without breaking labels,
+ secure boot, recovery or manufacturing flows.
diff --git a/docs/releases/JAGUAR-SCREEN-WAYDROID-v1.0.0.md b/docs/releases/JAGUAR-SCREEN-WAYDROID-v1.0.0.md
new file mode 100644
index 00000000..aa2ad4a0
--- /dev/null
+++ b/docs/releases/JAGUAR-SCREEN-WAYDROID-v1.0.0.md
@@ -0,0 +1,45 @@
+# Jaguar Screen Waydroid v1.0.0
+
+Release date: 2026-09-07
+
+This release establishes the first reproducible Foundries-built Jaguar Screen
+image that boots directly into a GPU-accelerated LineageOS/Waydroid desktop on
+the physical 1920x1200 display.
+
+## Included
+
+- seamless U-Boot to Linux display handover;
+- upright Active Edge splash through the Weston handover;
+- automatic persistent Android image provisioning;
+- automatic Waydroid container, session and full-screen UI startup;
+- Binder, pressure stall and etnaviv host support;
+- Mesa GC600 acceleration in Weston and Android SurfaceFlinger;
+- a two-second final reboot watchdog, applied after unmount and sync;
+- offline Jaguar GPU Drive demo with live FPS and adjustable particle load.
+
+## Evidence
+
+- Board: `imx8mm-jaguar-screen-2210a09dab86563`
+- First proven Foundries image: target 2887
+- Resolution: 1920x1200 logical, 1200x1920 native panel scanout
+- Android renderer: `Vivante GC600 rev 4653`
+- API: `OpenGL ES 2.0 Mesa 26.0.1`
+- Demo baseline: approximately 13 fps at 10,000 particles
+
+Target 2888 was rejected after physical validation: disabling the SPL watchdog
+did not remove the reboot delay and affected the proven boot presentation. A
+later release candidate must restore the target 2887 boot display path and
+prove the two-second final reboot watchdog on the board. A live configuration
+test reduced the measured gap from `systemd-shutdown: Rebooting` to SPL from
+59.5 seconds to 2.48 seconds. The release tags will identify the exact manifest
+and layer commits used by the final Foundries target.
+
+## Known limits
+
+- Android images are provisioned separately into persistent storage; they are
+ not yet fetched from a product-controlled image channel.
+- The GC600 exposes GLES 2.0. Chromium WebView 146 requests GLES 3 and
+ blocklists WebGL without development flags, so the release demo uses a
+ native GLES 2 application.
+- Touch mapping and the visual design of the benchmark have further refinement
+ opportunities after this baseline.
diff --git a/dynamic-layers/selinux/recipes-security/refpolicy/refpolicy-targeted/waydroid.fc b/dynamic-layers/selinux/recipes-security/refpolicy/refpolicy-targeted/waydroid.fc
new file mode 100644
index 00000000..7e4bad82
--- /dev/null
+++ b/dynamic-layers/selinux/recipes-security/refpolicy/refpolicy-targeted/waydroid.fc
@@ -0,0 +1,7 @@
+/usr/bin/waydroid -- gen_context(system_u:object_r:waydroid_exec_t,s0)
+/usr/libexec/waydroid-image-provision -- gen_context(system_u:object_r:waydroid_exec_t,s0)
+/usr/libexec/waydroid-jaguar-wait -- gen_context(system_u:object_r:waydroid_exec_t,s0)
+/usr/lib/waydroid/data/scripts/waydroid-net\.sh -- gen_context(system_u:object_r:waydroid_exec_t,s0)
+
+/var/lib/waydroid(/.*)? gen_context(system_u:object_r:waydroid_var_lib_t,s0)
+/run/waydroid-lxc(/.*)? gen_context(system_u:object_r:waydroid_runtime_t,s0)
diff --git a/dynamic-layers/selinux/recipes-security/refpolicy/refpolicy-targeted/waydroid.if b/dynamic-layers/selinux/recipes-security/refpolicy/refpolicy-targeted/waydroid.if
new file mode 100644
index 00000000..6659268c
--- /dev/null
+++ b/dynamic-layers/selinux/recipes-security/refpolicy/refpolicy-targeted/waydroid.if
@@ -0,0 +1,17 @@
+## Waydroid Android container runtime.
+
+########################################
+##
+## Execute Waydroid in the Waydroid domain.
+##
+##
+## Domain allowed to transition.
+##
+interface(`waydroid_domtrans',`
+ gen_require(`
+ type waydroid_t, waydroid_exec_t;
+ ')
+
+ corecmd_search_bin($1)
+ domtrans_pattern($1, waydroid_exec_t, waydroid_t)
+')
diff --git a/dynamic-layers/selinux/recipes-security/refpolicy/refpolicy-targeted/waydroid.te b/dynamic-layers/selinux/recipes-security/refpolicy/refpolicy-targeted/waydroid.te
new file mode 100644
index 00000000..a7a30758
--- /dev/null
+++ b/dynamic-layers/selinux/recipes-security/refpolicy/refpolicy-targeted/waydroid.te
@@ -0,0 +1,21 @@
+policy_module(waydroid, 1.0.0)
+
+########################################
+# Declarations
+
+type waydroid_t;
+type waydroid_exec_t;
+init_daemon_domain(waydroid_t, waydroid_exec_t)
+
+type waydroid_var_lib_t;
+files_type(waydroid_var_lib_t)
+
+type waydroid_runtime_t;
+files_runtime_file(waydroid_runtime_t)
+
+type waydroid_rootfs_t;
+files_type(waydroid_rootfs_t)
+
+# Replaced with a permissive declaration only for an explicitly selected
+# Waydroid policy-discovery build. Release policy is enforcing by default.
+# WAYDROID_DEVELOPMENT_PERMISSIVE
diff --git a/dynamic-layers/selinux/recipes-security/refpolicy/refpolicy-targeted_%.bbappend b/dynamic-layers/selinux/recipes-security/refpolicy/refpolicy-targeted_%.bbappend
new file mode 100644
index 00000000..88c01722
--- /dev/null
+++ b/dynamic-layers/selinux/recipes-security/refpolicy/refpolicy-targeted_%.bbappend
@@ -0,0 +1,35 @@
+FILESEXTRAPATHS:prepend := "${THISDIR}/${PN}:"
+
+# Refpolicy builds policy text with explicitly declared host-native SELinux
+# tools and passes BUILD_CC to its makefiles. It has no target-compiled code,
+# so the default target compiler/libc dependency is both unused and especially
+# expensive in LmP's global Clang configuration.
+INHIBIT_DEFAULT_DEPS = "1"
+
+WAYDROID_SELINUX_DEVELOPMENT_PERMISSIVE ?= "0"
+WAYDROID_SELINUX_POLICY_DISCOVERY ?= "0"
+
+python __anonymous() {
+ if (bb.utils.contains('DISTRO_FEATURES', 'waydroid', True, False, d)
+ and d.getVar('WAYDROID_SELINUX_DEVELOPMENT_PERMISSIVE') == '1'
+ and d.getVar('WAYDROID_SELINUX_POLICY_DISCOVERY') != '1'):
+ bb.fatal('A permissive Waydroid SELinux domain requires WAYDROID_SELINUX_POLICY_DISCOVERY=1')
+}
+
+SRC_URI:append = "${@bb.utils.contains('DISTRO_FEATURES', 'waydroid', ' file://waydroid.te file://waydroid.fc file://waydroid.if', '', d)}"
+EXTRA_OEMAKE:append = "${@bb.utils.contains('DISTRO_FEATURES', 'waydroid', ' APPS_MODS=waydroid', '', d)}"
+
+# refpolicy's `make conf` discovers modules below policy/modules. Install the
+# product module before upstream generates modules.conf and compiles policy.
+do_compile:prepend() {
+ if ${@bb.utils.contains('DISTRO_FEATURES', 'waydroid', 'true', 'false', d)}; then
+ install -d ${S}/policy/modules/services
+ install -m 0644 ${WORKDIR}/waydroid.te ${S}/policy/modules/services/
+ install -m 0644 ${WORKDIR}/waydroid.fc ${S}/policy/modules/services/
+ install -m 0644 ${WORKDIR}/waydroid.if ${S}/policy/modules/services/
+ if [ "${WAYDROID_SELINUX_DEVELOPMENT_PERMISSIVE}" = "1" ]; then
+ sed -i 's/^# WAYDROID_DEVELOPMENT_PERMISSIVE$/permissive waydroid_t;/' \
+ ${S}/policy/modules/services/waydroid.te
+ fi
+ fi
+}
diff --git a/kas/base.yml b/kas/base.yml
index 0f4bc79d..e8ca7e42 100644
--- a/kas/base.yml
+++ b/kas/base.yml
@@ -51,6 +51,11 @@ repos:
meta-parsec:
meta-integrity:
+ meta-selinux:
+ url: https://git.yoctoproject.org/meta-selinux
+ commit: 48f745109a1ecea9afe2a74d41dbd90fa7b370af
+ path: build/layers/meta-selinux
+
meta-updater:
url: https://github.com/lmp-mirrors/meta-updater
commit: b275153c9c8ea09e27d41db9f2faba3ebf92d2c2
diff --git a/kas/r26-jaguar-screen-selinux-enforcing-smoke.yml b/kas/r26-jaguar-screen-selinux-enforcing-smoke.yml
new file mode 100644
index 00000000..aeac9558
--- /dev/null
+++ b/kas/r26-jaguar-screen-selinux-enforcing-smoke.yml
@@ -0,0 +1,10 @@
+header:
+ version: 14
+ includes:
+ - r26-jaguar-screen-selinux.yml
+
+# Local enforcing-policy smoke configuration. Signing remains disabled by the
+# included development configuration, so this is not a production image.
+local_conf_header:
+ r26-jaguar-screen-selinux-enforcing-smoke: |
+ WAYDROID_SELINUX_DEVELOPMENT_PERMISSIVE = "0"
diff --git a/kas/r26-jaguar-screen-selinux.yml b/kas/r26-jaguar-screen-selinux.yml
new file mode 100644
index 00000000..914874e5
--- /dev/null
+++ b/kas/r26-jaguar-screen-selinux.yml
@@ -0,0 +1,33 @@
+header:
+ version: 14
+ includes:
+ - lmp-dynamicdevices.yml
+
+machine: imx8mm-jaguar-screen
+distro: lmp-dynamicdevices
+target: lmp-factory-image
+
+local_conf_header:
+ r26-jaguar-screen-selinux: |
+ DD_PRODUCT_FEATURES = "display android-container"
+ WAYDROID_SELINUX_DEVELOPMENT_PERMISSIVE = "1"
+ WAYDROID_SELINUX_POLICY_DISCOVERY = "1"
+ ASSEMBLE_SYSTEM_IMAGE = "0"
+ LOCAL_DEVELOPMENT_BUILD = "1"
+ OPTEE_TA_SIGN_ENABLE = "0"
+ SIGN_ENABLE = "0"
+ UBOOT_SIGN_ENABLE = "0"
+ UBOOT_SPL_SIGN_ENABLE = "0"
+ TF_A_SIGN_ENABLE = "0"
+ UEFI_SIGN_ENABLE = "0"
+ MODSIGN_ENABLE = "0"
+ MODSIGN = "0"
+ DISTRO_FEATURES:remove = "modsign"
+ SIGNING_UBOOT_SIGN_KEY = "${TOPDIR}/bitbake.lock"
+ SIGNING_UBOOT_SIGN_CRT = "${TOPDIR}/bitbake.lock"
+ SIGNING_UBOOT_SPL_SIGN_KEY = "${TOPDIR}/bitbake.lock"
+ SIGNING_UBOOT_SPL_SIGN_CRT = "${TOPDIR}/bitbake.lock"
+ SIGNING_UEFI_SIGN_KEY = "${TOPDIR}/bitbake.lock"
+ SIGNING_UEFI_SIGN_CRT = "${TOPDIR}/bitbake.lock"
+ OPTEE_TA_SIGN_KEY = "${TOPDIR}/bitbake.lock"
+ TF_A_SIGN_KEY_PATH = "${TOPDIR}/bitbake.lock"
diff --git a/meta-dynamicdevices-bsp b/meta-dynamicdevices-bsp
index b926d4e9..42744503 160000
--- a/meta-dynamicdevices-bsp
+++ b/meta-dynamicdevices-bsp
@@ -1 +1 @@
-Subproject commit b926d4e96532fb95c83984b154d7b2f72d82471e
+Subproject commit 427445038189759aa44df82c6d7d8a22c9e25cd4
diff --git a/meta-dynamicdevices-distro b/meta-dynamicdevices-distro
index 9807961b..c926b492 160000
--- a/meta-dynamicdevices-distro
+++ b/meta-dynamicdevices-distro
@@ -1 +1 @@
-Subproject commit 9807961bf74bb6c20eb60c0cd388b91b69883a41
+Subproject commit c926b49277ce7679820576708a58e2e9ae758e7a
diff --git a/recipes-containers/lxc/lxc_git.bbappend b/recipes-containers/lxc/lxc_git.bbappend
new file mode 100644
index 00000000..985e5ecc
--- /dev/null
+++ b/recipes-containers/lxc/lxc_git.bbappend
@@ -0,0 +1,4 @@
+# lxc meson+ninja fails at configure when clang ThinLTO is enabled but the
+# default Yocto linker is ld.bfd:
+# ERROR: LLVM's ThinLTO only works with gold, lld, lld-link, ld64 or mold, not ld.bfd
+EXTRA_OEMESON:append = " -Db_lto=false"
diff --git a/recipes-support/waydroid/python3-gbinder_git.bb b/recipes-support/waydroid/python3-gbinder_git.bb
index 038e7997..27c8e833 100644
--- a/recipes-support/waydroid/python3-gbinder_git.bb
+++ b/recipes-support/waydroid/python3-gbinder_git.bb
@@ -7,9 +7,10 @@ LICENSE = "GPL-3.0-only"
SECTION = "devel/python"
LIC_FILES_CHKSUM = "file://LICENSE;md5=1ebbd3e34237af26da5dc08a4e440464"
-# We're stuck @ 1.1.1 untill we are at cython3, build breaks with https://github.com/waydroid/gbinder-python/commit/4d8cb8f56da9e8159ea1b2ef76ddfa0253563db7
-PV = "1.1.1+git${SRCPV}"
-SRCREV = "990c3007eeac3e015fb38aecd76dd010b4b75a1e"
+# 1.1.1 + old Cython fails on Scarthgap (Python 3.12 / Cython 3.x). bullseye @ 4d8cb8f+
+# adds explicit noexcept for Cython 3; 1.1.2 is current bullseye tip.
+PV = "1.1.2+git${SRCPV}"
+SRCREV = "5089d76d4cd958cedda0028ffd752c25508dd382"
SRC_URI = "git://github.com/waydroid/gbinder-python.git;branch=bullseye;protocol=https \
file://0001-setup.py-Migrate-away-from-deprecated-distutils.core.patch \
"
diff --git a/recipes-support/waydroid/waydroid.bb b/recipes-support/waydroid/waydroid.bb
index 759aed6c..1aa4ae9c 100644
--- a/recipes-support/waydroid/waydroid.bb
+++ b/recipes-support/waydroid/waydroid.bb
@@ -11,7 +11,6 @@ SRCREV = "41f309f4c185a2c716723c081274eb56eb9263ff"
SPV = "1.4.2"
PV = "${SPV}+git${SRCPV}"
-
RDEPENDS:${PN} += "lxc python3-gbinder python3-pygobject libgbinder python3-pyclip python3-dbus python3-compression python3-json gobject-introspection"
# these modules are directly included in android-flavored kernels
@@ -24,6 +23,15 @@ RRECOMMENDS:${PN} += "\
SRC_URI = "git://github.com/herrie82/waydroid.git;branch=herrie/luneos;protocol=https \
file://gbinder.conf \
file://waydroid-net.sh \
+ file://waydroid-image-provision \
+ file://waydroid-image-provision.service \
+ file://waydroid-jaguar-wait \
+ file://waydroid-jaguar-container.service \
+ file://waydroid-jaguar-session.service \
+ file://waydroid-jaguar-ui.service \
+ file://weston-jaguar-waydroid.ini \
+ file://90-waydroid-screen.conf \
+ file://0001-mount-Android-rootfs-with-host-SELinux-context.patch \
"
S = "${WORKDIR}/git"
@@ -40,12 +48,28 @@ COMPATIBLE_MACHINE:pinetab2 = "(.*)"
COMPATIBLE_MACHINE:mido-halium = "(.*)"
COMPATIBLE_MACHINE:tissot = "(.*)"
COMPATIBLE_MACHINE:imx8mm-lpddr4-evk = "(.*)"
+COMPATIBLE_MACHINE:imx8mm-jaguar-screen = "(.*)"
+COMPATIBLE_MACHINE:imx95-frdm-evk = "(.*)"
inherit pkgconfig
#inherit webos_app
#inherit webos_filesystem_paths
#inherit webos_systemd
-inherit systemd
+inherit features_check systemd
+
+SYSTEMD_SERVICE:${PN}:imx8mm-jaguar-screen = " \
+ waydroid-image-provision.service \
+ waydroid-jaguar-container.service \
+ waydroid-jaguar-session.service \
+ waydroid-jaguar-ui.service \
+"
+SYSTEMD_AUTO_ENABLE:${PN}:imx8mm-jaguar-screen = "enable"
+
+# Product configuration selects the provider-neutral `android-container`
+# bundle. The distro layer expands that bundle to these implementation
+# prerequisites; fail early if Waydroid is pulled into an incomplete image.
+REQUIRED_DISTRO_FEATURES = "waydroid wayland opengl vulkan"
+REQUIRED_DISTRO_FEATURES:imx8mm-jaguar-screen = "waydroid wayland opengl etnaviv"
WEBOS_SYSTEMD_SERVICE = "waydroid-init.service waydroid-container.service"
@@ -57,6 +81,13 @@ do_install() {
make install_luneos DESTDIR=${D}
}
+do_install:append() {
+ install -Dm0755 ${WORKDIR}/waydroid-image-provision \
+ ${D}${libexecdir}/waydroid-image-provision
+ install -Dm0644 ${WORKDIR}/waydroid-image-provision.service \
+ ${D}${systemd_system_unitdir}/waydroid-image-provision.service
+}
+
# Provided by libgbinder already for Halium devices, but necessary to add for non-Halium devices.
do_install:append:pinephone() {
@@ -80,11 +111,48 @@ do_install:append:imx8mm-lpddr4-evk() {
install -m 755 ${WORKDIR}/waydroid-net.sh ${D}/usr/lib/waydroid/data/scripts/waydroid-net.sh
}
+do_install:append:imx8mm-jaguar-screen() {
+ install -Dm644 -t "${D}${sysconfdir}" "${WORKDIR}/gbinder.conf"
+ install -m 755 ${WORKDIR}/waydroid-net.sh ${D}/usr/lib/waydroid/data/scripts/waydroid-net.sh
+
+ # LXC executes hook paths. The inherited LuneOS template uses /dev/null
+ # as a no-op post-stop hook, which exits 126 and makes every clean Waydroid
+ # shutdown look like a container failure. Use an executable no-op.
+ config_base="${D}${libdir}/waydroid/data/configs/config_base"
+ if ! grep -qx 'lxc.hook.post-stop = /dev/null' "${config_base}"; then
+ bbfatal "unexpected Waydroid post-stop hook in ${config_base}"
+ fi
+ sed -i 's|^lxc.hook.post-stop = /dev/null$|lxc.hook.post-stop = /bin/true|' \
+ "${config_base}"
+
+ # The display controller is card2 on this board; card0 is the boot
+ # framebuffer and card1 is the render-only Etnaviv node. Pinning card2
+ # prevents Weston from selecting the wrong KMS device after boot.
+ install -Dm0644 ${WORKDIR}/weston-jaguar-waydroid.ini \
+ ${D}${sysconfdir}/xdg/weston/waydroid-screen.ini
+ install -Dm0644 ${WORKDIR}/90-waydroid-screen.conf \
+ ${D}${systemd_system_unitdir}/weston.service.d/90-waydroid-screen.conf
+
+ install -Dm0755 ${WORKDIR}/waydroid-jaguar-wait \
+ ${D}${libexecdir}/waydroid-jaguar-wait
+ install -Dm0644 ${WORKDIR}/waydroid-jaguar-container.service \
+ ${D}${systemd_system_unitdir}/waydroid-jaguar-container.service
+ install -Dm0644 ${WORKDIR}/waydroid-jaguar-session.service \
+ ${D}${systemd_system_unitdir}/waydroid-jaguar-session.service
+ install -Dm0644 ${WORKDIR}/waydroid-jaguar-ui.service \
+ ${D}${systemd_system_unitdir}/waydroid-jaguar-ui.service
+}
+
do_install:append:raspberrypi4-64() {
install -Dm644 -t "${D}${sysconfdir}" "${WORKDIR}/gbinder.conf"
install -m 755 ${WORKDIR}/waydroid-net.sh ${D}/usr/lib/waydroid/data/scripts/waydroid-net.sh
}
+do_install:append:imx95-frdm-evk() {
+ install -Dm644 -t "${D}${sysconfdir}" "${WORKDIR}/gbinder.conf"
+ install -m 755 ${WORKDIR}/waydroid-net.sh ${D}/usr/lib/waydroid/data/scripts/waydroid-net.sh
+}
+
FILES:${PN} += " \
${sysconfdir} \
${libdir} \
@@ -93,22 +161,3 @@ FILES:${PN} += " \
${prefix}/libexec \
/usr/palm/applications/id.waydro.container \
"
-
-
-# Usage
-# =====
-# Below is obsolete since Waydroid can now just be started from Launcher, however it's good to keep for reference
-#
-# mkdir -p /run/luna-session/
-# mount --bind /tmp/luna-session /run/luna-session/
-# export XDG_RUNTIME_DIR=/run/luna-session
-# export XDG_SESSION_TYPE=wayland
-# -- also, make sure /etc/gbinder.conf has "ApiLevel = 30" (Halium 9 needs API 28)
-#
-# Then:
-# 0. waydroid init (just once, but needs network !)
-# 1. either
-# waydroid show-full-ui
-# or
-# waydroid session start
-# waydroid app launch com.android.settings
diff --git a/recipes-support/waydroid/waydroid/0001-mount-Android-rootfs-with-host-SELinux-context.patch b/recipes-support/waydroid/waydroid/0001-mount-Android-rootfs-with-host-SELinux-context.patch
new file mode 100644
index 00000000..aba8f708
--- /dev/null
+++ b/recipes-support/waydroid/waydroid/0001-mount-Android-rootfs-with-host-SELinux-context.patch
@@ -0,0 +1,86 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Alex J Lennon
+Date: Sun, 13 Sep 2026 10:05:00 +0100
+Subject: [PATCH] mount Android rootfs with host SELinux context
+
+Android image xattrs name Android policy types which are unknown to the LXC
+host policy. When the host has SELinux active, apply one host-owned mount
+context to the system, vendor and overlay mounts. This keeps Android image
+contents confined without treating them as unlabeled host files.
+
+Upstream-Status: Inappropriate [product host-policy integration]
+---
+ tools/helpers/images.py | 17 +++++++++++++----
+ tools/helpers/mount.py | 4 +++-
+ 2 files changed, 16 insertions(+), 5 deletions(-)
+
+diff --git a/tools/helpers/images.py b/tools/helpers/images.py
+index f266db3..abf51a4 100644
+--- a/tools/helpers/images.py
++++ b/tools/helpers/images.py
+@@ -154,28 +154,37 @@ def make_prop(args, cfg, full_props_path):
+
+ def mount_rootfs(args, images_dir, session):
+ cfg = tools.config.load(args)
++ mount_context = None
++ mount_options = None
++ if os.path.exists("/sys/fs/selinux/enforce"):
++ mount_context = "system_u:object_r:waydroid_rootfs_t:s0"
++ mount_options = ["context=" + mount_context]
+ helpers.mount.mount(args, images_dir + "/system.img",
+- tools.config.defaults["rootfs"], umount=True)
++ tools.config.defaults["rootfs"], umount=True,
++ options=mount_options)
+ if cfg["waydroid"]["mount_overlays"] == "True":
+ try:
+ helpers.mount.mount_overlay(args, [tools.config.defaults["overlay"],
+ tools.config.defaults["rootfs"]],
+ tools.config.defaults["rootfs"],
+ upper_dir=tools.config.defaults["overlay_rw"] + "/system",
+- work_dir=tools.config.defaults["overlay_work"] + "/system")
++ work_dir=tools.config.defaults["overlay_work"] + "/system",
++ mount_context=mount_context)
+ except RuntimeError:
+ cfg["waydroid"]["mount_overlays"] = "False"
+ tools.config.save(args, cfg)
+ logging.warning("Mounting overlays failed. The feature has been disabled.")
+
+ helpers.mount.mount(args, images_dir + "/vendor.img",
+- tools.config.defaults["rootfs"] + "/vendor")
++ tools.config.defaults["rootfs"] + "/vendor",
++ options=mount_options)
+ if cfg["waydroid"]["mount_overlays"] == "True":
+ helpers.mount.mount_overlay(args, [tools.config.defaults["overlay"] + "/vendor",
+ tools.config.defaults["rootfs"] + "/vendor"],
+ tools.config.defaults["rootfs"] + "/vendor",
+ upper_dir=tools.config.defaults["overlay_rw"] + "/vendor",
+- work_dir=tools.config.defaults["overlay_work"] + "/vendor")
++ work_dir=tools.config.defaults["overlay_work"] + "/vendor",
++ mount_context=mount_context)
+
+ for egl_path in ["/vendor/lib/egl", "/vendor/lib64/egl"]:
+ if os.path.isdir(egl_path):
+diff --git a/tools/helpers/mount.py b/tools/helpers/mount.py
+index 236ff5b..5a9c915 100644
+--- a/tools/helpers/mount.py
++++ b/tools/helpers/mount.py
+@@ -152,7 +152,7 @@ def mount(args, source, destination, create_folders=True, umount=False,
+ raise RuntimeError("Mount failed: " + source + " -> " + destination)
+
+ def mount_overlay(args, lower_dirs, destination, upper_dir=None, work_dir=None,
+- create_folders=True, readonly=True):
++ create_folders=True, readonly=True, mount_context=None):
+ """
+ Mount an overlay.
+ """
+@@ -167,6 +167,8 @@ def mount_overlay(args, lower_dirs, destination, upper_dir=None, work_dir=None,
+
+ if kernel_version() >= versiontuple("4.17"):
+ options.append("xino=off")
++ if mount_context:
++ options.append("context=" + mount_context)
+
+ for dir_path in dirs:
+ if not os.path.exists(dir_path):
+--
+2.43.0
diff --git a/recipes-support/waydroid/waydroid/90-waydroid-screen.conf b/recipes-support/waydroid/waydroid/90-waydroid-screen.conf
new file mode 100644
index 00000000..655d290d
--- /dev/null
+++ b/recipes-support/waydroid/waydroid/90-waydroid-screen.conf
@@ -0,0 +1,5 @@
+[Service]
+WorkingDirectory=/var/rootdirs/home/weston
+SupplementaryGroups=render video
+ExecStart=
+ExecStart=/usr/bin/weston --drm-device=card2 --config=/etc/xdg/weston/waydroid-screen.ini --modules=systemd-notify.so --log=/var/rootdirs/home/weston/weston.log
diff --git a/recipes-support/waydroid/waydroid/waydroid-image-provision b/recipes-support/waydroid/waydroid/waydroid-image-provision
new file mode 100644
index 00000000..7af9103d
--- /dev/null
+++ b/recipes-support/waydroid/waydroid/waydroid-image-provision
@@ -0,0 +1,18 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-only
+
+set -eu
+
+images_dir=/var/lib/waydroid/images
+config=/var/lib/waydroid/waydroid.cfg
+
+if [ -s "${images_dir}/system.img" ] && [ -s "${images_dir}/vendor.img" ]; then
+ exit 0
+fi
+
+# An interrupted first initialization can leave configuration claiming a
+# channel revision whose image was never fully installed. Let Waydroid build
+# that configuration again before its checksum-verified channel download.
+rm -f "${config}"
+
+exec /usr/bin/waydroid init
diff --git a/recipes-support/waydroid/waydroid/waydroid-image-provision.service b/recipes-support/waydroid/waydroid/waydroid-image-provision.service
new file mode 100644
index 00000000..30a7ebd2
--- /dev/null
+++ b/recipes-support/waydroid/waydroid/waydroid-image-provision.service
@@ -0,0 +1,20 @@
+[Unit]
+Description=Provision Waydroid Android images into persistent storage
+Wants=network-online.target
+After=network-online.target dbus.service
+Before=waydroid-container.service
+StartLimitIntervalSec=0
+
+[Service]
+Type=oneshot
+# OTA preserves /var, so migrate pre-SELinux Waydroid state to the policy
+# labels shipped by this deployment before entering the Waydroid domain.
+ExecStartPre=-/sbin/restorecon -RF /var/lib/waydroid
+ExecStart=/usr/libexec/waydroid-image-provision
+RemainAfterExit=yes
+Restart=on-failure
+RestartSec=60
+TimeoutStartSec=infinity
+
+[Install]
+WantedBy=multi-user.target
diff --git a/recipes-support/waydroid/waydroid/waydroid-jaguar-container.service b/recipes-support/waydroid/waydroid/waydroid-jaguar-container.service
new file mode 100644
index 00000000..edf0fbdd
--- /dev/null
+++ b/recipes-support/waydroid/waydroid/waydroid-jaguar-container.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=Waydroid container on Jaguar Screen
+Requires=waydroid-image-provision.service
+After=waydroid-image-provision.service dbus.service
+ConditionPathExists=/var/lib/waydroid/images/system.img
+ConditionPathExists=/var/lib/waydroid/images/vendor.img
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/waydroid container start
+Restart=on-failure
+RestartSec=5
+TimeoutStopSec=45
+
+[Install]
+WantedBy=multi-user.target
diff --git a/recipes-support/waydroid/waydroid/waydroid-jaguar-session.service b/recipes-support/waydroid/waydroid/waydroid-jaguar-session.service
new file mode 100644
index 00000000..7f8334c4
--- /dev/null
+++ b/recipes-support/waydroid/waydroid/waydroid-jaguar-session.service
@@ -0,0 +1,26 @@
+[Unit]
+Description=Waydroid graphical session on Jaguar Screen
+Requires=waydroid-jaguar-container.service weston.service
+After=waydroid-jaguar-container.service weston.service
+BindsTo=weston.service
+
+[Service]
+Type=simple
+User=weston
+Group=weston
+SupplementaryGroups=video render wayland
+Environment=HOME=/var/rootdirs/home/weston
+# Weston has the fixed system UID 63 in this image. System-service specifier
+# %U expands to the manager UID (root), so keep the proven runtime path here.
+Environment=XDG_RUNTIME_DIR=/run/user/63
+Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/63/bus
+Environment=WAYLAND_DISPLAY=wayland-1
+# Starting the session asks the already-running ContainerManager over D-Bus to
+# boot LXC. Waiting for LXC here would deadlock that request.
+ExecStart=/usr/bin/waydroid session start
+Restart=on-failure
+RestartSec=5
+TimeoutStopSec=45
+
+[Install]
+WantedBy=graphical.target
diff --git a/recipes-support/waydroid/waydroid/waydroid-jaguar-ui.service b/recipes-support/waydroid/waydroid/waydroid-jaguar-ui.service
new file mode 100644
index 00000000..e1ce7d4d
--- /dev/null
+++ b/recipes-support/waydroid/waydroid/waydroid-jaguar-ui.service
@@ -0,0 +1,22 @@
+[Unit]
+Description=Present the Waydroid full-screen UI on Jaguar Screen
+Requires=waydroid-jaguar-session.service
+After=waydroid-jaguar-session.service
+PartOf=waydroid-jaguar-session.service
+
+[Service]
+Type=oneshot
+User=weston
+Group=weston
+SupplementaryGroups=video render wayland
+Environment=HOME=/var/rootdirs/home/weston
+Environment=XDG_RUNTIME_DIR=/run/user/63
+Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/63/bus
+Environment=WAYLAND_DISPLAY=wayland-1
+ExecStartPre=/usr/libexec/waydroid-jaguar-wait session
+ExecStart=/usr/bin/waydroid show-full-ui
+RemainAfterExit=yes
+TimeoutStartSec=180
+
+[Install]
+WantedBy=graphical.target
diff --git a/recipes-support/waydroid/waydroid/waydroid-jaguar-wait b/recipes-support/waydroid/waydroid/waydroid-jaguar-wait
new file mode 100644
index 00000000..141c6a4c
--- /dev/null
+++ b/recipes-support/waydroid/waydroid/waydroid-jaguar-wait
@@ -0,0 +1,23 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-only
+
+set -eu
+
+state=${1:?usage: waydroid-jaguar-wait container|session}
+case "${state}" in
+ container) pattern='Container:[[:space:]]*RUNNING' ;;
+ session) pattern='Session:[[:space:]]*RUNNING' ;;
+ *) echo "unsupported Waydroid state: ${state}" >&2; exit 2 ;;
+esac
+
+attempt=0
+while [ "${attempt}" -lt 120 ]; do
+ if /usr/bin/waydroid status 2>/dev/null | grep -Eq "${pattern}"; then
+ exit 0
+ fi
+ attempt=$((attempt + 1))
+ sleep 1
+done
+
+echo "timed out waiting for Waydroid ${state}" >&2
+exit 1
diff --git a/recipes-support/waydroid/waydroid/weston-jaguar-waydroid.ini b/recipes-support/waydroid/waydroid/weston-jaguar-waydroid.ini
new file mode 100644
index 00000000..f55a623e
--- /dev/null
+++ b/recipes-support/waydroid/waydroid/weston-jaguar-waydroid.ini
@@ -0,0 +1,17 @@
+[core]
+repaint-window=16
+idle-time=0
+
+[shell]
+background-image=/usr/share/screen-splash/active-edge-splash-1920x1200.png
+background-type=scale
+background-color=0xff07111f
+panel-position=none
+
+[libinput]
+touchscreen_calibrator=true
+
+[output]
+name=DSI-1
+mode=1200x1920
+transform=rotate-90
diff --git a/scripts/README.md b/scripts/README.md
index ed87194e..e992ff3c 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -29,6 +29,7 @@ This directory contains utility scripts for building, testing, managing, and aut
| `rdc-control.sh` | Hardware Control | Resource domain control | i.MX8MM RDC management |
| `validation/validate-layers.sh` | Quality Assurance | Yocto layer validation | Project compatibility |
| `validate-layers-local.sh` | Quality Assurance | Comprehensive yocto-check-layer validation | KAS-based local validation |
+| `validation/capture-r26-waydroid-board-evidence.sh` | Hardware Validation | Read-only Jaguar Screen SELinux/Waydroid acceptance capture | Release/development modes and optional H.264 decode proof |
## 📋 Table of Contents
diff --git a/scripts/validation/capture-layer-state.sh b/scripts/validation/capture-layer-state.sh
index 28c101f1..afc1a8eb 100755
--- a/scripts/validation/capture-layer-state.sh
+++ b/scripts/validation/capture-layer-state.sh
@@ -2,8 +2,8 @@
# Build one protected tuple and capture deterministic layer/package/task state.
set -euo pipefail
-if [ "$#" -ne 6 ]; then
- echo "Usage: $0 KAS_CONFIG MACHINE DISTRO TARGET PRODUCT_FEATURES OUTPUT_DIR" >&2
+if [ "$#" -ne 7 ]; then
+ echo "Usage: $0 KAS_CONFIG MACHINE DISTRO TARGET PRODUCT_FEATURES VARIABLES_JSON OUTPUT_DIR" >&2
exit 2
fi
@@ -12,7 +12,8 @@ machine=$2
distro=$3
target=$4
product_features=$5
-output_dir=$6
+variables_json=$6
+output_dir=$7
test_keys_dir=${LAYER_ADOPTION_TEST_KEYS_DIR:-}
cache_root=${LAYER_ADOPTION_CACHE_DIR:-$HOME/yocto}
@@ -52,6 +53,22 @@ export DISTRO="$distro"
product_features_quoted=$(python3 -c 'import json, sys; print(json.dumps(sys.argv[1]))' "$product_features")
downloads_quoted=$(python3 -c 'import json, sys; print(json.dumps(sys.argv[1]))' "$cache_root/downloads")
sstate_quoted=$(python3 -c 'import json, sys; print(json.dumps(sys.argv[1]))' "$cache_root/sstate-cache")
+variable_assignments=$(python3 - "$variables_json" <<'PY'
+import json
+import re
+import sys
+
+variables = json.loads(sys.argv[1])
+if not isinstance(variables, dict):
+ raise SystemExit("tuple variables must be a JSON object")
+for name, value in sorted(variables.items()):
+ if not isinstance(name, str) or not re.fullmatch(r"[A-Z0-9_]+(?::[a-z0-9_-]+)*", name):
+ raise SystemExit(f"invalid BitBake variable name: {name!r}")
+ if not isinstance(value, str):
+ raise SystemExit(f"BitBake variable {name} must have a string value")
+ print(f" {name} = {json.dumps(value)}")
+PY
+)
repository_root=$(git rev-parse --show-toplevel)
overlay_dir="$repository_root/.layer-adoption-overlays"
mkdir -p "$overlay_dir"
@@ -67,8 +84,26 @@ header:
local_conf_header:
layer-adoption-product-features: |
DD_PRODUCT_FEATURES = $product_features_quoted
+$variable_assignments
DL_DIR = $downloads_quoted
SSTATE_DIR = $sstate_quoted
+ # Use a stable, deliberately selected CI probe rather than OE-core's
+ # release-specific default URL. Source fetches remain independently fatal.
+ CONNECTIVITY_CHECK_URIS = "https://www.example.com/"
+ # docker-compose vendors golang.org/x/oauth2 from go.googlesource.com.
+ # Prefer its official GitHub mirror so this protected tuple is not coupled
+ # to one source host; BitBake still verifies the recipe-pinned SRCREV.
+ PREMIRRORS:append = " git://go.googlesource.com/oauth2 git://github.com/golang/oauth2.git;protocol=https \n"
+ # Foundries tuples use PATCHTOOL=git, which can make bison's generated
+ # manual appear stale. Provide the generator hermetically rather than
+ # depending on an undeclared package in the CI container.
+ DEPENDS:append:pn-bison-native = " help2man-native"
+ # runc vendors src/import as a Git submodule. PATCHTOOL=git applies the
+ # recipe patch inside that nested tree but then tries to commit only the
+ # parent repository, leaving the submodule dirty and failing do_patch.
+ # Keep the Foundries-wide Git patch policy and isolate this recipe to the
+ # standard Quilt backend for identical baseline and candidate builds.
+ PATCHTOOL:pn-runc-opencontainers = "quilt"
BB_DISKMON_DIRS = "STOPTASKS,\${TMPDIR},20G,100K STOPTASKS,\${DL_DIR},20G,100K STOPTASKS,\${SSTATE_DIR},20G,100K HALT,\${TMPDIR},10G,50K HALT,\${DL_DIR},10G,50K HALT,\${SSTATE_DIR},10G,50K"
UBOOT_SIGN_KEYDIR:forcevariable = "$test_keys_dir"
UBOOT_SPL_SIGN_KEYDIR:forcevariable = "$test_keys_dir"
@@ -90,9 +125,22 @@ EOF
combined_config="${config}:${overlay}"
kas checkout "$combined_config"
-cat > "$output_dir/metadata.json" < "$output_dir/commands.txt"
+ "DD_PRODUCT_FEATURES=$product_features" \
+ "TUPLE_VARIABLES=$variables_json" > "$output_dir/commands.txt"
# Static layer surfaces are captured as well as BitBake's resolved view. This
# makes wildcard/dangling appends and global layer.conf policy visible even
@@ -224,6 +274,13 @@ require_selected_value OPTEE_TA_SIGN_KEY "/ubootdev.key"
require_selected_value TF_A_SIGN_KEY_PATH "/tf-a/privkey_ec_prime256v1.pem"
rm "$output_dir/environment.log"
+# Fail fast on the large docker-compose Go source set instead of discovering
+# an unavailable vendor repository after hours of unrelated compilation.
+if python3 -c 'import json,sys; raise SystemExit(0 if json.loads(sys.argv[1]).get("DOCKER_COMPOSE_APP") == "1" else 1)' "$variables_json"; then
+ capture_command fetch-docker-compose run_bitbake \
+ "bitbake -c fetch docker-compose" >/dev/null
+fi
+
# A parse-only graph is not proof that packaging, signing, recovery image size,
# or deploy layout still works. Complete the real image/recovery build for both
# baseline and candidate.
diff --git a/scripts/validation/capture-r26-waydroid-board-evidence.sh b/scripts/validation/capture-r26-waydroid-board-evidence.sh
new file mode 100755
index 00000000..3559d206
--- /dev/null
+++ b/scripts/validation/capture-r26-waydroid-board-evidence.sh
@@ -0,0 +1,173 @@
+#!/bin/sh
+# Capture read-only R26 Waydroid acceptance evidence on a Jaguar Screen board.
+# shellcheck disable=SC2016 # Dollar expressions in single quotes run in sh -c.
+
+set -eu
+
+usage() {
+ echo "usage: $0 OUTPUT_DIR [--release] [--h264 RAW_H264_FILE]" >&2
+ exit 2
+}
+
+[ "$#" -ge 1 ] || usage
+output_dir=$1
+shift
+release=0
+h264_file=
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --release) release=1 ;;
+ --h264)
+ [ "$#" -ge 2 ] || usage
+ h264_file=$2
+ shift
+ ;;
+ *) usage ;;
+ esac
+ shift
+done
+
+umask 077
+mkdir -p "$output_dir"
+summary="$output_dir/summary.tsv"
+: >"$summary"
+
+capture() {
+ name=$1
+ shift
+ {
+ echo "command: $*"
+ echo "captured_utc: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ echo
+ "$@"
+ } >"$output_dir/$name.txt" 2>&1 || true
+}
+
+record() {
+ printf '%s\t%s\t%s\n' "$1" "$2" "$3" >>"$summary"
+}
+
+expect_output() {
+ check=$1
+ expected=$2
+ shift 2
+ actual=$("$@" 2>&1 || true)
+ if printf '%s\n' "$actual" | grep -Eq "$expected"; then
+ record "$check" PASS "$(printf '%s' "$actual" | tr '\n\t' ' ')"
+ else
+ record "$check" FAIL "$(printf '%s' "$actual" | tr '\n\t' ' ')"
+ fi
+}
+
+capture identity uname -a
+capture os-release sh -c 'cat /etc/os-release; echo; cat /etc/lmp-version 2>/dev/null || true'
+capture kernel-command-line cat /proc/cmdline
+capture selinux-status sh -c 'getenforce; sestatus; semodule -lfull; semanage permissive -l 2>/dev/null || true'
+capture process-contexts ps -eZ
+capture waydroid-status waydroid status
+capture waydroid-processes sh -c 'ps -eZ | grep -E "[w]aydroid|[l]xc" || true'
+capture service-status systemctl --no-pager --full status \
+ waydroid-image-provision.service waydroid-jaguar-container.service \
+ waydroid-jaguar-session.service waydroid-jaguar-ui.service weston.service \
+ auditd.service cra-audit-queue-processor.timer
+capture service-journal journalctl --no-pager -b -u waydroid-image-provision.service \
+ -u waydroid-jaguar-container.service -u waydroid-jaguar-session.service \
+ -u waydroid-jaguar-ui.service -u weston.service
+capture labels sh -c 'ls -ldZ /var/lib/waydroid /var/lib/waydroid/images /run/waydroid-lxc 2>/dev/null; matchpathcon /var/lib/waydroid /run/waydroid-lxc /usr/bin/waydroid 2>/dev/null || true'
+capture device-labels sh -c 'ls -lZ /dev/binder* /dev/vndbinder* /dev/hwbinder* /dev/dri/* /dev/video* 2>/dev/null || true'
+capture binder waydroid shell service list
+capture surfaceflinger waydroid shell dumpsys SurfaceFlinger
+capture android-low-ram sh -c 'waydroid shell getprop ro.config.low_ram; waydroid shell getprop ro.lmk.low; waydroid shell getprop ro.lmk.medium; waydroid shell getprop ro.lmk.critical'
+capture memory sh -c 'cat /proc/meminfo; echo; cat /proc/swaps; echo; zramctl 2>/dev/null || true; echo; for z in /sys/block/zram*; do for n in disksize comp_algorithm mem_used_total; do f="$z/$n"; [ ! -e "$f" ] || { printf "%s: " "$f"; cat "$f"; }; done; done'
+capture gpu sh -c 'dmesg | grep -Ei "etnaviv|galcore|drm" || true; echo; waydroid shell dumpsys SurfaceFlinger 2>/dev/null | grep -Ei "GLES|EGL|render" || true'
+capture v4l2 sh -c 'v4l2-ctl --list-devices 2>/dev/null || true; echo; gst-inspect-1.0 v4l2h264dec 2>/dev/null || gst-inspect-1.0 v4l2slh264dec 2>/dev/null || true; echo; dmesg | grep -Ei "v4l2|vpu|hantro|h264" || true'
+capture audio sh -c 'pactl info 2>/dev/null || wpctl status 2>/dev/null || true; echo; waydroid shell dumpsys audio 2>/dev/null || true'
+capture network waydroid shell ip -brief address
+capture ota-state sh -c 'aktualizr-lite status 2>/dev/null || true; echo; ostree admin status 2>/dev/null || true; echo; fw_printenv 2>/dev/null | grep -Ei "bootcount|bootlimit|rollback|upgrade_available" || true'
+capture secure-boot sh -c 'dmesg | grep -Ei "secure boot|hab|ahab|caam|dm-verity|verified boot" || true'
+capture image-hashes sh -c 'sha256sum /var/lib/waydroid/images/*.img 2>/dev/null || true'
+capture avc-denials sh -c 'ausearch -m AVC,USER_AVC -ts boot 2>/dev/null || journalctl -k -b --no-pager | grep -Ei "avc:.*denied" || true'
+
+expect_output host-selinux '^Enforcing$' getenforce
+if grep -Eq '(^| )(selinux=0|enforcing=0)( |$)' /proc/cmdline; then
+ record kernel-selinux-arguments FAIL "$(cat /proc/cmdline)"
+else
+ record kernel-selinux-arguments PASS "SELinux is not disabled on the kernel command line"
+fi
+expect_output waydroid-module '(^|[[:space:]])waydroid([[:space:]]|$)' semodule -lfull
+if ps -eZ | awk '
+ /[w]aydroid|[l]xc/ {
+ found = 1
+ if ($1 !~ /:waydroid_t:/) bad = 1
+ }
+ END { exit !(found && !bad) }
+'; then
+ record waydroid-domain PASS 'every visible Waydroid/LXC process is in waydroid_t'
+else
+ record waydroid-domain FAIL 'missing Waydroid/LXC process or one is outside waydroid_t; see waydroid-processes.txt'
+fi
+expect_output binder-service-list '^[[:space:]]*[0-9]+[[:space:]]' waydroid shell service list
+expect_output surfaceflinger-running 'GLES|EGL|Display' waydroid shell dumpsys SurfaceFlinger
+services_ok=1
+for service in waydroid-image-provision.service waydroid-jaguar-container.service \
+ waydroid-jaguar-session.service waydroid-jaguar-ui.service weston.service; do
+ systemctl is-active --quiet "$service" || services_ok=0
+done
+if [ "$services_ok" -eq 1 ]; then
+ record kiosk-services PASS 'all Waydroid and Weston units are active'
+else
+ record kiosk-services FAIL 'one or more Waydroid/Weston units are inactive; see service-status.txt'
+fi
+if systemctl is-active --quiet auditd.service \
+ && systemctl is-active --quiet cra-audit-queue-processor.timer; then
+ record cra-audit-runtime PASS 'auditd and CRA queue processor timer are active'
+else
+ record cra-audit-runtime FAIL 'auditd or CRA queue processor timer is inactive; see service-status.txt'
+fi
+expect_output waydroid-network 'UP|UNKNOWN' waydroid shell ip -brief address
+expect_output zram-active '/dev/zram' sh -c 'cat /proc/swaps'
+expect_output android-low-ram '^(true|1)$' waydroid shell getprop ro.config.low_ram
+expect_output etnaviv-active 'etnaviv' sh -c 'dmesg | grep -i etnaviv'
+expect_output hardware-renderer 'etnaviv|Vivante|GC[0-9]+' sh -c 'waydroid shell dumpsys SurfaceFlinger | grep -Ei "GLES|EGL|render"'
+
+if [ "$release" -eq 1 ]; then
+ if semanage permissive -l 2>/dev/null | grep -qx 'waydroid_t'; then
+ record waydroid-enforcing FAIL 'waydroid_t is listed as permissive'
+ else
+ record waydroid-enforcing PASS 'waydroid_t is not listed as permissive'
+ fi
+ if tail -n +4 "$output_dir/avc-denials.txt" | grep -Eqi 'avc:.*denied'; then
+ record unexplained-avc FAIL 'AVC denials require classification before release'
+ else
+ record unexplained-avc PASS 'no AVC denials found since boot'
+ fi
+else
+ record waydroid-enforcing NOT_RUN 'development capture; repeat with --release on enforcing candidate'
+ record unexplained-avc NOT_RUN 'development AVCs are evidence for policy refinement'
+fi
+
+if [ -n "$h264_file" ]; then
+ if [ ! -r "$h264_file" ]; then
+ record v4l2-h264-decode FAIL "unreadable input: $h264_file"
+ elif command -v gst-launch-1.0 >/dev/null 2>&1; then
+ decoder=v4l2h264dec
+ gst-inspect-1.0 "$decoder" >/dev/null 2>&1 || decoder=v4l2slh264dec
+ if timeout 120 gst-launch-1.0 -q filesrc location="$h264_file" ! \
+ h264parse ! "$decoder" ! fakesink sync=false \
+ >"$output_dir/v4l2-h264-decode.txt" 2>&1; then
+ record v4l2-h264-decode PASS "$decoder completed"
+ else
+ record v4l2-h264-decode FAIL "see v4l2-h264-decode.txt"
+ fi
+ else
+ record v4l2-h264-decode FAIL 'gst-launch-1.0 is unavailable'
+ fi
+else
+ record v4l2-h264-decode NOT_RUN 'supply --h264 RAW_H264_FILE for an actual decode proof'
+fi
+
+printf 'Evidence: %s\n' "$output_dir"
+printf 'Summary: %s\n' "$summary"
+if grep -q "$(printf '\t')FAIL$(printf '\t')" "$summary"; then
+ exit 1
+fi
diff --git a/scripts/validation/detect-layer-adoption.py b/scripts/validation/detect-layer-adoption.py
index a4e34cd2..c2b384da 100755
--- a/scripts/validation/detect-layer-adoption.py
+++ b/scripts/validation/detect-layer-adoption.py
@@ -30,7 +30,10 @@
re.compile(r"^ci/layer-adoption-tuples\.json$"),
)
-TUPLE_FIELDS = ("id", "machine", "distro", "image", "config", "product_features")
+TUPLE_FIELDS = (
+ "id", "machine", "distro", "image", "config", "product_features", "variables"
+)
+REQUIRED_TUPLE_FIELDS = tuple(field for field in TUPLE_FIELDS if field != "variables")
NONEMPTY_TUPLE_FIELDS = ("id", "machine", "distro", "image", "config")
@@ -43,7 +46,7 @@ def git(*args: str) -> str:
return subprocess.check_output(["git", *args], text=True)
-def parse_tuples(raw: str, source: str) -> dict[str, dict[str, str]]:
+def parse_tuples(raw: str, source: str) -> dict[str, dict[str, object]]:
try:
document = json.loads(raw)
except json.JSONDecodeError as exc:
@@ -51,21 +54,32 @@ def parse_tuples(raw: str, source: str) -> dict[str, dict[str, str]]:
if document.get("schema") != 1 or not isinstance(document.get("tuples"), list):
raise ValueError(f"{source}: expected schema=1 and a tuples array")
- result: dict[str, dict[str, str]] = {}
+ result: dict[str, dict[str, object]] = {}
for index, entry in enumerate(document["tuples"]):
if not isinstance(entry, dict):
raise ValueError(f"{source}: tuple {index} is not an object")
- missing = [field for field in TUPLE_FIELDS if field not in entry]
+ missing = [field for field in REQUIRED_TUPLE_FIELDS if field not in entry]
missing.extend(
field for field in NONEMPTY_TUPLE_FIELDS
if field in entry and not str(entry[field]).strip()
)
if missing:
raise ValueError(f"{source}: tuple {index} lacks {', '.join(missing)}")
+ variables = entry.get("variables", {})
+ if not isinstance(variables, dict) or any(
+ not isinstance(name, str)
+ or not re.fullmatch(r"[A-Z0-9_]+(?::[a-z0-9_-]+)*", name)
+ or not isinstance(value, str)
+ for name, value in variables.items()
+ ):
+ raise ValueError(f"{source}: tuple {index} has invalid variables")
tuple_id = str(entry["id"])
if tuple_id in result:
raise ValueError(f"{source}: duplicate tuple id {tuple_id}")
- result[tuple_id] = {field: str(entry[field]) for field in TUPLE_FIELDS}
+ result[tuple_id] = {
+ field: (dict(sorted(variables.items())) if field == "variables" else str(entry[field]))
+ for field in TUPLE_FIELDS
+ }
if not result:
raise ValueError(f"{source}: tuple matrix must not be empty")
return result
diff --git a/scripts/validation/run-layer-adoption-regression.py b/scripts/validation/run-layer-adoption-regression.py
index 0ce953a4..d8db81d1 100644
--- a/scripts/validation/run-layer-adoption-regression.py
+++ b/scripts/validation/run-layer-adoption-regression.py
@@ -16,7 +16,8 @@
MARKER = ".complete.json"
LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1\n"
-FIELDS = ("id", "machine", "distro", "image", "config", "product_features")
+FIELDS = ("id", "machine", "distro", "image", "config", "product_features", "variables")
+REQUIRED_FIELDS = tuple(field for field in FIELDS if field != "variables")
PRODUCT_SUBMODULES = ("meta-dynamicdevices-bsp", "meta-dynamicdevices-distro")
CAPTURE_SCHEMA_FILES = (
"ci/layer-adoption-contract.json",
@@ -71,16 +72,27 @@ def valid_cached_evidence(
}
-def load_tuples(path: Path) -> list[dict[str, str]]:
+def load_tuples(path: Path) -> list[dict[str, object]]:
document = json.loads(path.read_text(encoding="utf-8"))
if document.get("schema") != 1 or not isinstance(document.get("tuples"), list):
raise ValueError(f"{path}: expected schema=1 and a tuples array")
tuples = []
seen = set()
for index, raw in enumerate(document["tuples"]):
- if not isinstance(raw, dict) or any(field not in raw for field in FIELDS):
+ if not isinstance(raw, dict) or any(field not in raw for field in REQUIRED_FIELDS):
raise ValueError(f"{path}: tuple {index} is incomplete")
- entry = {field: str(raw[field]) for field in FIELDS}
+ entry: dict[str, object] = {
+ field: str(raw[field]) for field in FIELDS if field != "variables"
+ }
+ variables = raw.get("variables", {})
+ if not isinstance(variables, dict) or any(
+ not isinstance(name, str)
+ or not re.fullmatch(r"[A-Z0-9_]+(?::[a-z0-9_-]+)*", name)
+ or not isinstance(value, str)
+ for name, value in variables.items()
+ ):
+ raise ValueError(f"{path}: tuple {index} has invalid variables")
+ entry["variables"] = dict(sorted(variables.items()))
if not entry["id"] or entry["id"] in seen:
raise ValueError(f"{path}: duplicate or empty tuple id {entry['id']!r}")
seen.add(entry["id"])
@@ -91,8 +103,8 @@ def load_tuples(path: Path) -> list[dict[str, str]]:
def select_tuples(
- tuples: list[dict[str, str]], tuple_id: str | None
-) -> list[dict[str, str]]:
+ tuples: list[dict[str, object]], tuple_id: str | None
+) -> list[dict[str, object]]:
"""Select one CI shard while keeping the local default as the full gate."""
if tuple_id is None:
return tuples
@@ -210,15 +222,23 @@ def apply_baseline_repairs(
raise ValueError("baseline repair needs a reason and exact file list")
if submodule_commit(baseline, relative) != old:
raise RuntimeError(f"baseline repair {relative}: unexpected source pin")
- if submodule_commit(candidate, relative) != new:
- raise RuntimeError(f"baseline repair {relative}: unexpected candidate pin")
-
candidate_layer = candidate / relative
+ candidate_commit = submodule_commit(candidate, relative)
subprocess.run(
["git", "merge-base", "--is-ancestor", old, new],
cwd=candidate_layer,
check=True,
)
+ descendant = subprocess.run(
+ ["git", "merge-base", "--is-ancestor", new, candidate_commit],
+ cwd=candidate_layer,
+ check=False,
+ )
+ if descendant.returncode != 0:
+ raise RuntimeError(
+ f"baseline repair {relative}: candidate {candidate_commit} "
+ f"does not contain audited repair {new}"
+ )
changed = git_output(candidate_layer, "diff", "--name-only", old, new).splitlines()
if sorted(changed) != sorted(str(path) for path in files):
raise RuntimeError(
@@ -244,18 +264,19 @@ def apply_baseline_repairs(
def capture(
script: Path,
repository: Path,
- entry: dict[str, str],
+ entry: dict[str, object],
output: Path,
environment: dict[str, str],
) -> None:
subprocess.run(
[
str(script),
- entry["config"],
- entry["machine"],
- entry["distro"],
- entry["image"],
- entry["product_features"],
+ str(entry["config"]),
+ str(entry["machine"]),
+ str(entry["distro"]),
+ str(entry["image"]),
+ str(entry["product_features"]),
+ json.dumps(entry["variables"], sort_keys=True, separators=(",", ":")),
str(output),
],
cwd=repository,
@@ -296,7 +317,7 @@ def main() -> int:
prepare_repository(baseline)
prepare_repository(candidate)
apply_baseline_repairs(baseline, candidate, contract, base_sha)
- for config in sorted({entry["config"] for entry in tuples}):
+ for config in sorted({str(entry["config"]) for entry in tuples}):
materialize_config(baseline, config)
materialize_config(candidate, config)
@@ -308,7 +329,7 @@ def main() -> int:
(evidence / "candidate").mkdir(parents=True)
for entry in tuples:
- tuple_id = entry["id"]
+ tuple_id = str(entry["id"])
print(f"::group::Protect {tuple_id}", flush=True)
cached = cache / "baselines" / base_sha / tuple_id
baseline_output = evidence / "baseline" / tuple_id
diff --git a/scripts/validation/tests/test_capture_layer_state.py b/scripts/validation/tests/test_capture_layer_state.py
index f8e60742..cd2d8378 100644
--- a/scripts/validation/tests/test_capture_layer_state.py
+++ b/scripts/validation/tests/test_capture_layer_state.py
@@ -159,6 +159,24 @@ def test_disk_monitor_uses_inode_not_disk_units(self) -> None:
self.assertEqual(disk_monitor.count(",100K"), 3)
self.assertEqual(disk_monitor.count(",50K"), 3)
+ def test_tuple_variables_are_validated_and_injected_into_overlay(self) -> None:
+ source = SCRIPT.read_text(encoding="utf-8")
+ self.assertIn("variables_json=$6", source)
+ self.assertIn('CONNECTIVITY_CHECK_URIS = "https://www.example.com/"', source)
+ self.assertIn(
+ "git://go.googlesource.com/oauth2 "
+ "git://github.com/golang/oauth2.git;protocol=https",
+ source,
+ )
+ self.assertIn(
+ 'DEPENDS:append:pn-bison-native = " help2man-native"', source
+ )
+ self.assertIn('PATCHTOOL:pn-runc-opencontainers = "quilt"', source)
+ self.assertIn('"bitbake -c fetch docker-compose"', source)
+ self.assertIn('get("DOCKER_COMPOSE_APP") == "1"', source)
+ self.assertIn('re.fullmatch(r"[A-Z0-9_]+(?::[a-z0-9_-]+)*", name)', source)
+ self.assertIn("$variable_assignments", source)
+
if __name__ == "__main__":
unittest.main()
diff --git a/scripts/validation/tests/test_detect_layer_adoption.py b/scripts/validation/tests/test_detect_layer_adoption.py
index a1aecf21..28933e13 100644
--- a/scripts/validation/tests/test_detect_layer_adoption.py
+++ b/scripts/validation/tests/test_detect_layer_adoption.py
@@ -31,10 +31,17 @@ def entry(tuple_id: str, machine: str = "machine-a") -> dict[str, str]:
"image": "image-a",
"config": "kas/test.yml",
"product_features": "",
+ "variables": {},
}
class ProtectedTupleTests(unittest.TestCase):
+ def test_legacy_tuple_without_variables_normalises_to_empty_mapping(self) -> None:
+ legacy = entry("existing")
+ del legacy["variables"]
+ parsed = MODULE.parse_tuples(document(legacy), "legacy")
+ self.assertEqual(parsed["existing"]["variables"], {})
+
def test_local_and_ci_kas_process_changes_are_material(self) -> None:
paths = (
".github/workflows/layer-adoption-gate.yml",
@@ -101,6 +108,18 @@ def test_redefining_product_features_fails(self) -> None:
with self.assertRaisesRegex(ValueError, "redefined=existing"):
self.validate(document(entry("existing")), document(candidate))
+ def test_redefining_tuple_variables_fails(self) -> None:
+ candidate = entry("existing")
+ candidate["variables"] = {"DEV_MODE": "0"}
+ with self.assertRaisesRegex(ValueError, "redefined=existing"):
+ self.validate(document(entry("existing")), document(candidate))
+
+ def test_invalid_variable_name_fails(self) -> None:
+ candidate = entry("existing")
+ candidate["variables"] = {"bad name": "1"}
+ with self.assertRaisesRegex(ValueError, "invalid variables"):
+ self.validate(document(entry("existing")), document(candidate))
+
if __name__ == "__main__":
unittest.main()
diff --git a/scripts/validation/tests/test_r26_waydroid_board_evidence.py b/scripts/validation/tests/test_r26_waydroid_board_evidence.py
new file mode 100644
index 00000000..1763fb01
--- /dev/null
+++ b/scripts/validation/tests/test_r26_waydroid_board_evidence.py
@@ -0,0 +1,53 @@
+import pathlib
+import unittest
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[3]
+SCRIPT = ROOT / "scripts/validation/capture-r26-waydroid-board-evidence.sh"
+
+
+class BoardEvidenceCollectorTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.source = SCRIPT.read_text(encoding="utf-8")
+
+ def test_release_mode_checks_domain_and_avcs(self):
+ self.assertIn("semanage permissive -l", self.source)
+ self.assertIn("waydroid-enforcing FAIL", self.source)
+ self.assertIn("unexplained-avc FAIL", self.source)
+
+ def test_acceptance_surfaces_are_captured(self):
+ for evidence in (
+ "binder-service-list",
+ "surfaceflinger-running",
+ "kiosk-services",
+ "waydroid-network",
+ "zram-active",
+ "android-low-ram",
+ "etnaviv-active",
+ "hardware-renderer",
+ "v4l2-h264-decode",
+ "ota-state",
+ "secure-boot",
+ "image-hashes",
+ "cra-audit-runtime",
+ ):
+ self.assertIn(evidence, self.source)
+
+ def test_h264_is_exercised_not_inferred(self):
+ self.assertIn("gst-launch-1.0", self.source)
+ self.assertIn("h264parse", self.source)
+ self.assertIn("fakesink", self.source)
+ self.assertIn("NOT_RUN", self.source)
+
+ def test_every_waydroid_process_must_be_confined(self):
+ self.assertIn("found && !bad", self.source)
+ self.assertIn("every visible Waydroid/LXC process is in waydroid_t", self.source)
+
+ def test_collector_does_not_update_or_reboot(self):
+ for forbidden in ("fioctl update", "aktualizr-lite update", "reboot", "shutdown"):
+ self.assertNotIn(forbidden, self.source)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/validation/tests/test_run_layer_adoption_regression.py b/scripts/validation/tests/test_run_layer_adoption_regression.py
index 04722f1f..0ebc4b25 100644
--- a/scripts/validation/tests/test_run_layer_adoption_regression.py
+++ b/scripts/validation/tests/test_run_layer_adoption_regression.py
@@ -80,7 +80,16 @@ def test_workflow_shards_all_tuples_without_fail_fast(self) -> None:
self.assertIn("fail-fast: false", workflow)
self.assertIn("fromJSON(needs.detect.outputs.tuple_ids)", workflow)
self.assertIn("--tuple-id '${{ matrix.tuple_id }}'", workflow)
- self.assertIn("name: Layer Adoption Gate", workflow)
+ self.assertIn("merge_group:", workflow)
+ self.assertIn("types: [checks_requested]", workflow)
+ self.assertIn("github.event.merge_group.base_sha", workflow)
+ self.assertIn("github.event_name != 'merge_group'", workflow)
+ self.assertIn("Development validation — baseline comparison", workflow)
+ self.assertIn("Product readiness validation — baseline comparison", workflow)
+ self.assertIn("Development Validation", workflow)
+ self.assertIn("imx8mm-jaguar-screen-waydroid-image", workflow)
+ self.assertIn("unknown protected tuple", workflow)
+ self.assertIn("'Layer Adoption Gate'", workflow)
def test_audited_baseline_repair_is_exact_and_fail_closed(self) -> None:
old = "a" * 40
@@ -114,14 +123,18 @@ def test_audited_baseline_repair_is_exact_and_fail_closed(self) -> None:
MODULE,
"git_output",
side_effect=[changed_file + "\n", new, ""],
- ), mock.patch.object(MODULE.subprocess, "run") as run:
+ ), mock.patch.object(
+ MODULE.subprocess,
+ "run",
+ return_value=subprocess.CompletedProcess([], 0),
+ ) as run:
MODULE.apply_baseline_repairs(
root / "baseline", root / "candidate", contract, "base"
)
- self.assertEqual(run.call_count, 3)
+ self.assertEqual(run.call_count, 4)
self.assertEqual(
- run.call_args_list[1].args[0],
+ run.call_args_list[2].args[0],
[
"git",
"fetch",
@@ -130,6 +143,44 @@ def test_audited_baseline_repair_is_exact_and_fail_closed(self) -> None:
],
)
+ def test_audited_repair_must_be_ancestor_of_candidate_pin(self) -> None:
+ old = "a" * 40
+ repair = "b" * 40
+ candidate = "c" * 40
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ contract = root / "contract.json"
+ contract.write_text(
+ json.dumps(
+ {
+ "baseline_repairs": [
+ {
+ "base_sha": "base",
+ "submodule": "meta-dynamicdevices-bsp",
+ "from": old,
+ "to": repair,
+ "url": "https://github.com/DynamicDevices/bsp.git",
+ "ref": "refs/heads/focused-backport",
+ "files": ["fix.patch"],
+ "reason": "focused repair",
+ }
+ ]
+ }
+ ),
+ encoding="utf-8",
+ )
+ ancestry = [
+ subprocess.CompletedProcess([], 0),
+ subprocess.CompletedProcess([], 1),
+ ]
+ with mock.patch.object(
+ MODULE, "submodule_commit", side_effect=[old, candidate]
+ ), mock.patch.object(MODULE.subprocess, "run", side_effect=ancestry):
+ with self.assertRaisesRegex(RuntimeError, "does not contain audited repair"):
+ MODULE.apply_baseline_repairs(
+ root / "baseline", root / "candidate", contract, "base"
+ )
+
def test_audited_baseline_repair_rejects_extra_files(self) -> None:
old = "a" * 40
new = "b" * 40
@@ -159,7 +210,11 @@ def test_audited_baseline_repair_rejects_extra_files(self) -> None:
MODULE, "submodule_commit", side_effect=[old, new]
), mock.patch.object(
MODULE, "git_output", return_value="unexpected.patch\n"
- ), mock.patch.object(MODULE.subprocess, "run"):
+ ), mock.patch.object(
+ MODULE.subprocess,
+ "run",
+ return_value=subprocess.CompletedProcess([], 0),
+ ):
with self.assertRaisesRegex(RuntimeError, "do not match contract"):
MODULE.apply_baseline_repairs(
root / "baseline", root / "candidate", contract, "base"
@@ -253,7 +308,9 @@ def test_worktree_preparation_rejects_unpinned_layer(self) -> None:
def test_gate_does_not_run_bitbake_as_root(self) -> None:
workflow = WORKFLOW_PATH.read_text(encoding="utf-8")
self.assertNotIn("--user 0:0", workflow)
- self.assertIn("--user 1002:1002", workflow)
+ self.assertIn("--user 999:995", workflow)
+ self.assertIn("dd-esl-proxmox", workflow)
+ self.assertNotIn("ai-tools", workflow)
def test_valid_cache_is_accepted_and_tampering_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as directory:
diff --git a/scripts/validation/tests/test_waydroid_selinux_policy.py b/scripts/validation/tests/test_waydroid_selinux_policy.py
new file mode 100644
index 00000000..b5a364ff
--- /dev/null
+++ b/scripts/validation/tests/test_waydroid_selinux_policy.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""Fail closed if the Waydroid SELinux development escape hatch broadens."""
+
+from pathlib import Path
+import subprocess
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[3]
+APPEND = ROOT / "dynamic-layers/selinux/recipes-security/refpolicy/refpolicy-targeted_%.bbappend"
+POLICY = ROOT / "dynamic-layers/selinux/recipes-security/refpolicy/refpolicy-targeted/waydroid.te"
+DEVELOPMENT_KAS = ROOT / "kas/r26-jaguar-screen-selinux.yml"
+ENFORCING_KAS = ROOT / "kas/r26-jaguar-screen-selinux-enforcing-smoke.yml"
+AUDITED_DISTRO_COMMIT = "c926b49277ce7679820576708a58e2e9ae758e7a"
+
+
+class WaydroidSelinuxPolicyTest(unittest.TestCase):
+ def test_tracked_policy_is_not_permissive(self) -> None:
+ self.assertNotIn("permissive waydroid_t;", POLICY.read_text(encoding="utf-8"))
+
+ def test_permissive_mode_defaults_off_and_is_development_gated(self) -> None:
+ text = APPEND.read_text(encoding="utf-8")
+ self.assertIn('WAYDROID_SELINUX_DEVELOPMENT_PERMISSIVE ?= "0"', text)
+ self.assertIn('WAYDROID_SELINUX_POLICY_DISCOVERY ?= "0"', text)
+ self.assertIn("d.getVar('WAYDROID_SELINUX_POLICY_DISCOVERY') != '1'", text)
+ self.assertIn("bb.fatal(", text)
+
+ def test_discovery_stack_opts_in_explicitly(self) -> None:
+ text = DEVELOPMENT_KAS.read_text(encoding="utf-8")
+ self.assertIn('LOCAL_DEVELOPMENT_BUILD = "1"', text)
+ self.assertIn('WAYDROID_SELINUX_DEVELOPMENT_PERMISSIVE = "1"', text)
+ self.assertIn('WAYDROID_SELINUX_POLICY_DISCOVERY = "1"', text)
+
+ def test_enforcing_smoke_stack_opts_out_explicitly(self) -> None:
+ text = ENFORCING_KAS.read_text(encoding="utf-8")
+ self.assertIn('WAYDROID_SELINUX_DEVELOPMENT_PERMISSIVE = "0"', text)
+
+ def test_cra_runtime_uses_the_audited_distro_pin(self) -> None:
+ """Keep CI independent of an initialized submodule worktree.
+
+ The exact-manifest preflight validates the contents of this immutable
+ distro commit, including the screen-and-SELinux-only audit runtime.
+ This source gate ensures the product continues to reference that
+ audited content address.
+ """
+ entry = subprocess.check_output(
+ ["git", "ls-tree", "HEAD", "meta-dynamicdevices-distro"],
+ cwd=ROOT,
+ text=True,
+ ).split()
+ self.assertEqual(entry[:2], ["160000", "commit"])
+ self.assertEqual(entry[2], AUDITED_DISTRO_COMMIT)
+
+
+if __name__ == "__main__":
+ unittest.main()