From 85f1ecabd7f58f306b2a042376209ac8cf49ab9b Mon Sep 17 00:00:00 2001 From: cirakg Date: Sun, 18 Oct 2026 21:10:37 +0200 Subject: build: complete the Docker pipeline for build, test, screenshots and packaging - Container scripts now run from the mounted project (no image rebuild per script change): build-app.sh (strict -l 3, warnings fail), run-unit-tests.sh, screenshots.sh, app-diag.sh (perf/memory), lint.sh, package.sh and sim-vnc.sh (noVNC on 127.0.0.1:6080). - Headless simulator helpers: wait for port 1234, remove the stale single-instance lock (~/Sim-dev) that made later starts hang, show the simulator log on failure. - New targets: screenshots-approve, icons; `make lock` re-adds the requirements header; pytest treats warnings as errors. - Author/Summary headers on every build file. Co-Authored-By: Claude Opus 5.5 --- docker/scripts/app-diag.sh | 36 ++++++++++++++++ docker/scripts/build-app.sh | 49 +++++++++++++++++++++ docker/scripts/ciq-env.sh | 93 +++++++++++++++++++++++++++++++++++++++- docker/scripts/gen-key.sh | 3 ++ docker/scripts/lint.sh | 33 ++++++++++++++ docker/scripts/package.sh | 45 +++++++++++++++++++ docker/scripts/run-unit-tests.sh | 46 ++++++++++++++++++++ docker/scripts/screenshots.sh | 48 +++++++++++++++++++++ docker/scripts/sdk-setup.sh | 3 ++ docker/scripts/sim-vnc.sh | 39 +++++++++++++++++ 10 files changed, 394 insertions(+), 1 deletion(-) create mode 100755 docker/scripts/app-diag.sh create mode 100755 docker/scripts/build-app.sh create mode 100755 docker/scripts/lint.sh create mode 100755 docker/scripts/package.sh create mode 100755 docker/scripts/run-unit-tests.sh create mode 100755 docker/scripts/screenshots.sh create mode 100755 docker/scripts/sim-vnc.sh (limited to 'docker/scripts') diff --git a/docker/scripts/app-diag.sh b/docker/scripts/app-diag.sh new file mode 100755 index 0000000..804d68a --- /dev/null +++ b/docker/scripts/app-diag.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Author: cirakg & claude ai +# Summary: Run the debug app on each device in the headless simulator and check start-up time or memory budgets. +# +# Usage: app-diag.sh perf|mem DEVICE [DEVICE ...] +# Output: build/diag/.log (console with DIAG and HARNESS lines) and +# build/diag/-summary.json; verdict from tools/check_budgets.py. +set -euo pipefail +# shellcheck source=ciq-env.sh +source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh" + +kind="${1:?usage: app-diag.sh perf|mem DEVICE [DEVICE ...]}" +shift +[[ "${kind}" == perf || "${kind}" == mem ]] || die "unknown kind '${kind}' (use perf or mem)" +(($# > 0)) || die "usage: app-diag.sh perf|mem DEVICE [DEVICE ...]" +ciq_require_sdk +ciq_require_key + +for device in "$@"; do + build-app.sh debug "${device}" +done + +ciq_start_display +ciq_start_simulator + +out="${BUILD_DIR}/diag" +mkdir -p "${out}" +logs=() +for device in "$@"; do + log "starting the app on ${device}" + ciq_launch_app "${device}" "${out}/${device}.log" || log "no first frame on ${device} within ${FIRST_FRAME_TIMEOUT_S} s" + ciq_stop_app + logs+=("${out}/${device}.log") +done + +python3 /work/tools/check_budgets.py "${kind}" "${logs[@]}" --summary "${out}/${kind}-summary.json" diff --git a/docker/scripts/build-app.sh b/docker/scripts/build-app.sh new file mode 100755 index 0000000..74dd042 --- /dev/null +++ b/docker/scripts/build-app.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Author: cirakg & claude ai +# Summary: Compile the watch app for one device: debug, release or unit-test build. +# +# Usage: build-app.sh debug|release|test DEVICE +# Output: build//.prg, and the compiler log next to it. +# +# Policy (README section 17.7): strict type checking (-l 3), and any compiler +# warning fails the build. Test builds add test.jungle, so test code never +# reaches debug or release builds; release builds (-r) also drop every +# (:debug) declaration, such as the diagnostics in source/util/Diag.mc. +set -euo pipefail +# shellcheck source=ciq-env.sh +source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh" + +mode="${1:?usage: build-app.sh debug|release|test DEVICE}" +device="${2:?usage: build-app.sh debug|release|test DEVICE}" + +ciq_require_sdk +ciq_require_key +ciq_require_device "${device}" + +# icons.jungle (generated by tools/make_icon.py --all) gives every device a +# launcher icon of exactly its size. +jungles="/work/monkey.jungle;/work/icons.jungle" +args=(-d "${device}" -y "${DEV_KEY}" -l 3 -w) +case "${mode}" in + debug) ;; + release) args+=(-r) ;; + test) + jungles="${jungles};/work/test.jungle" + args+=(-t) + ;; + *) die "unknown build mode '${mode}' (use debug, release or test)" ;; +esac + +out_dir="${BUILD_DIR}/${mode}" +mkdir -p "${out_dir}" +compile_log="${out_dir}/${device}.log" + +if ! monkeyc -f "${jungles}" -o "${out_dir}/${device}.prg" "${args[@]}" >"${compile_log}" 2>&1; then + cat "${compile_log}" >&2 + die "compile failed for ${device} (${mode} build)" +fi +if grep -q "^WARNING" "${compile_log}"; then + grep "^WARNING" "${compile_log}" >&2 + die "compiler warnings for ${device} (${mode} build); warnings count as errors" +fi +log "built build/${mode}/${device}.prg" diff --git a/docker/scripts/ciq-env.sh b/docker/scripts/ciq-env.sh index 945d59a..a52db78 100755 --- a/docker/scripts/ciq-env.sh +++ b/docker/scripts/ciq-env.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash -# Shared helpers for the container scripts. Source this file; don't run it. +# Author: cirakg & claude ai +# Summary: Shared helpers for the container scripts (SDK and key lookup, headless display and simulator). +# +# Source this file; don't run it. # # Paths, as seen inside the container: # /work the project folder (bind mount) @@ -12,6 +15,12 @@ KEY_DIR="/work/.cache/keys" DEV_KEY="${KEY_DIR}/developer_key.der" BUILD_DIR="/work/build" +# Virtual display for the headless simulator, and the TCP port (1234, hex +# 04D2 as in /proc/net/tcp) on which the simulator accepts monkeydo. +SIM_DISPLAY=":99" +SIM_PORT_HEX="04D2" +SIM_START_TIMEOUT_S=60 + # Print an error and stop. Used for every user-facing failure, so the message # says what to do next instead of leaving a bare stack trace. die() { @@ -47,3 +56,85 @@ ciq_require_device() { [[ -f "${CIQ_HOME}/Devices/${device}/compiler.json" ]] \ || die "No device files for '${device}'. Is it in manifest.xml? Then run 'make sdk' to download them." } + +# Start a virtual X display (Xvfb) with the openbox window manager. The window +# manager matters: screenshots and scripted input find the simulator window +# through it. +ciq_start_display() { + export DISPLAY="${SIM_DISPLAY}" + Xvfb "${DISPLAY}" -screen 0 1600x1200x24 -nolisten tcp >/tmp/xvfb.log 2>&1 & + local _ + for _ in $(seq 1 50); do + xdpyinfo >/dev/null 2>&1 && break + sleep 0.1 + done + xdpyinfo >/dev/null 2>&1 || die "Xvfb did not start (see /tmp/xvfb.log)." + openbox >/tmp/openbox.log 2>&1 & +} + +# True when something listens on the simulator's monkeydo port. +ciq_simulator_listening() { + awk -v port=":${SIM_PORT_HEX}" \ + 'NR > 1 && $4 == "0A" && substr($2, length($2) - 4) == port { found = 1 } END { exit !found }' \ + /proc/net/tcp +} + +# Start the Connect IQ simulator (needs ciq_require_sdk and ciq_start_display) +# and wait until it accepts monkeydo connections. +ciq_start_simulator() { + # The simulator's single-instance lock (~/Sim-) stores its process ID + # and survives in .cache/home after the container is gone. A later container + # often reuses that process ID for something else, and the simulator then + # believes it is already running and never opens its port. Each container + # runs exactly one simulator, so the stale lock is safe to remove. + rm -f "${HOME}"/Sim-* + simulator >/tmp/simulator.log 2>&1 & + local pid=$! _ + for _ in $(seq 1 $((SIM_START_TIMEOUT_S * 5))); do + ciq_simulator_listening && return 0 + kill -0 "${pid}" 2>/dev/null || break + sleep 0.2 + done + # The container (and /tmp) disappears after a failure, so show the log now. + echo "--- last lines of the simulator log ---" >&2 + tail -n 25 /tmp/simulator.log >&2 || true + die "The Connect IQ simulator did not start within ${SIM_START_TIMEOUT_S} s." +} + +# The simulator's main window ID, for screenshots and scripted input. +ciq_simulator_window() { + xdotool search --name '^CIQ Simulator' | head -n 1 +} + +# Longest wait for an app's first frame after launching it. +FIRST_FRAME_TIMEOUT_S=60 + +# Launch the debug build of the app on DEVICE and wait for its first frame, +# which the app reports with a "DIAG first_frame" console line. The console +# goes to LOG, followed by a "HARNESS first_frame wall_ms=N" line: the +# wall-clock time from launch to first frame, including the JVM start of +# monkeydo and the program load the app can't time itself. The app keeps +# running (for screenshots) until ciq_stop_app or the next launch. +# Returns 1 if no first frame appeared in time. +ciq_launch_app() { + local device="$1" app_log="$2" start _ + start="$(date +%s%3N)" + monkeydo "${BUILD_DIR}/debug/${device}.prg" "${device}" >"${app_log}" 2>&1 & + CIQ_MONKEYDO_PID=$! + for _ in $(seq 1 $((FIRST_FRAME_TIMEOUT_S * 20))); do + if grep -q "DIAG first_frame" "${app_log}"; then + echo "HARNESS first_frame wall_ms=$(($(date +%s%3N) - start))" >>"${app_log}" + return 0 + fi + # monkeydo only exits on its own when the app could not run. + kill -0 "${CIQ_MONKEYDO_PID}" 2>/dev/null || break + sleep 0.05 + done + return 1 +} + +# Stop the monkeydo session started by ciq_launch_app. +ciq_stop_app() { + kill "${CIQ_MONKEYDO_PID:-0}" 2>/dev/null || true + wait "${CIQ_MONKEYDO_PID:-0}" 2>/dev/null || true +} diff --git a/docker/scripts/gen-key.sh b/docker/scripts/gen-key.sh index 7b99863..015f9d9 100755 --- a/docker/scripts/gen-key.sh +++ b/docker/scripts/gen-key.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# Author: cirakg & claude ai +# Summary: Generate the RSA-4096 developer key that signs every build, never overwriting an existing one. +# # Generate the developer key that signs every build (README section 15.6). # # Output: .cache/keys/developer_key.der (PKCS#8 DER, what monkeyc wants) and diff --git a/docker/scripts/lint.sh b/docker/scripts/lint.sh new file mode 100755 index 0000000..09b8410 --- /dev/null +++ b/docker/scripts/lint.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Author: cirakg & claude ai +# Summary: All static checks: project policy, Python lint and types, and strict Monkey C builds without warnings. +# +# Usage: lint.sh DEVICE [DEVICE ...] +# +# 1. tools/lint_project.py: project rules (headers, no logging, no network, ...) +# 2. ruff (lint + formatting) and mypy --strict on the Python tools +# 3. tools/gen_products.py --check: the manifest's products meet the targets +# 4. Debug and release builds with strict type checking (-l 3) on each device; +# build-app.sh fails on any compiler warning. +set -euo pipefail +# shellcheck source=ciq-env.sh +source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh" + +(($# > 0)) || die "usage: lint.sh DEVICE [DEVICE ...]" + +log "project policy" +python3 /work/tools/lint_project.py /work + +log "Python tools: ruff and mypy" +ruff check /work/tools +ruff format --check /work/tools +(cd /work && mypy) + +log "product list" +python3 /work/tools/gen_products.py --check + +for device in "$@"; do + build-app.sh debug "${device}" + build-app.sh release "${device}" +done +log "lint passed" diff --git a/docker/scripts/package.sh b/docker/scripts/package.sh new file mode 100755 index 0000000..8d58e39 --- /dev/null +++ b/docker/scripts/package.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Author: cirakg & claude ai +# Summary: Build the signed .iq package for the Connect IQ Store beta, plus release .prg files for sideload tests. +# +# Usage: package.sh [DEVICE ...] (devices for the .prg files; default: TEST_DEVICES) +# Output: build/package/otp-wallet-.iq all 72 products, release, signed +# build/package/prg/.prg release builds for sideload tests +# +# The .iq is what you upload as a beta app (README section 4). It is built +# with the same strict settings as every other build: type check level 3 and +# no compiler warnings. +set -euo pipefail +# shellcheck source=ciq-env.sh +source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh" + +ciq_require_sdk +ciq_require_key + +version="$(sed -n 's/^ *const VERSION = "\([0-9.]*\)";.*/\1/p' /work/source/app/AppInfo.mc)" +[[ -n "${version}" ]] || die "could not read VERSION from source/app/AppInfo.mc" + +out="${BUILD_DIR}/package" +mkdir -p "${out}/prg" +iq="${out}/otp-wallet-${version}.iq" +compile_log="${out}/otp-wallet-${version}.log" + +log "exporting ${iq#/work/} for every product in manifest.xml (this takes a while)" +if ! monkeyc -e -r -f "/work/monkey.jungle;/work/icons.jungle" -o "${iq}" -y "${DEV_KEY}" -l 3 -w \ + >"${compile_log}" 2>&1; then + cat "${compile_log}" >&2 + die "packaging failed" +fi +if grep -q "^WARNING" "${compile_log}"; then + grep "^WARNING" "${compile_log}" >&2 + die "compiler warnings while packaging; warnings count as errors" +fi + +devices=("$@") +((${#devices[@]} > 0)) || read -r -a devices <<<"${TEST_DEVICES:-}" +for device in "${devices[@]}"; do + build-app.sh release "${device}" + cp "${BUILD_DIR}/release/${device}.prg" "${out}/prg/${device}.prg" +done + +log "package ready: ${iq#/work/} ($(du -h "${iq}" | cut -f1)); release .prg files in build/package/prg/" diff --git a/docker/scripts/run-unit-tests.sh b/docker/scripts/run-unit-tests.sh new file mode 100755 index 0000000..60a8955 --- /dev/null +++ b/docker/scripts/run-unit-tests.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Author: cirakg & claude ai +# Summary: Build and run the Monkey C unit tests on each test device in the headless simulator. +# +# Usage: run-unit-tests.sh DEVICE [DEVICE ...] +# Output: build/test/results/.log (console) and .xml (JUnit). +# +# Every test build is compiled first, so a compile error stops the run before +# the simulator starts. One simulator then runs every device in turn. +# monkeydo exits with 1 even when all tests pass, so the verdict comes from +# tools/ciq_test_report.py; the script fails if any device fails. +set -euo pipefail +# shellcheck source=ciq-env.sh +source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh" + +# Longest a single device's test run may take before it counts as hung. +readonly TEST_TIMEOUT_S=600 + +(($# > 0)) || die "usage: run-unit-tests.sh DEVICE [DEVICE ...]" +ciq_require_sdk +ciq_require_key + +for device in "$@"; do + build-app.sh test "${device}" +done + +ciq_start_display +ciq_start_simulator + +results="${BUILD_DIR}/test/results" +mkdir -p "${results}" +failed=0 +for device in "$@"; do + log "running unit tests on ${device}" + # monkeydo's exit status is ignored on purpose (see above); a timeout leaves + # an incomplete log, which the report then rejects. + timeout "${TEST_TIMEOUT_S}" monkeydo "${BUILD_DIR}/test/${device}.prg" "${device}" -t \ + >"${results}/${device}.log" 2>&1 || true + python3 /work/tools/ciq_test_report.py "${results}/${device}.log" \ + --device "${device}" --junit "${results}/${device}.xml" || failed=1 +done + +if ((failed)); then + die "unit tests failed; console logs are in build/test/results/" +fi +log "unit tests passed on: $*" diff --git a/docker/scripts/screenshots.sh b/docker/scripts/screenshots.sh new file mode 100755 index 0000000..3fc52f5 --- /dev/null +++ b/docker/scripts/screenshots.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Author: cirakg & claude ai +# Summary: Screenshot the app on each device in the headless simulator and compare with the approved golden images. +# +# Usage: screenshots.sh DEVICE [DEVICE ...] (APPROVE=1 saves new goldens) +# Output: build/screenshots/.png (the watch screen only), plus a +# -diff.png when it differs from test/golden/.png. +set -euo pipefail +# shellcheck source=ciq-env.sh +source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh" + +# Pause after the first frame, so animations and late redraws settle. +readonly SETTLE_S=1 + +(($# > 0)) || die "usage: screenshots.sh DEVICE [DEVICE ...]" +ciq_require_sdk +ciq_require_key + +for device in "$@"; do + build-app.sh debug "${device}" +done + +ciq_start_display +ciq_start_simulator + +out="${BUILD_DIR}/screenshots" +mkdir -p "${out}" +approve=() +[[ "${APPROVE:-0}" == 1 ]] && approve=(--approve) +failed=0 +for device in "$@"; do + if ! ciq_launch_app "${device}" "${out}/${device}.log"; then + ciq_stop_app + log "no first frame on ${device}; see build/screenshots/${device}.log" + failed=1 + continue + fi + sleep "${SETTLE_S}" + mkdir -p "${out}/raw" + import -window "$(ciq_simulator_window)" "${out}/raw/${device}-window.png" + ciq_stop_app + python3 /work/tools/sim_screenshot.py --raw "${out}/raw/${device}-window.png" \ + --device-dir "${CIQ_HOME}/Devices/${device}" --out "${out}/${device}.png" \ + --golden "/work/test/golden/${device}.png" "${approve[@]}" || failed=1 +done + +((failed == 0)) || die "screenshot checks failed; see build/screenshots/" +log "screenshots checked on: $*" diff --git a/docker/scripts/sdk-setup.sh b/docker/scripts/sdk-setup.sh index 278b060..1f14c58 100755 --- a/docker/scripts/sdk-setup.sh +++ b/docker/scripts/sdk-setup.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# Author: cirakg & claude ai +# Summary: Interactive `make sdk`: accept Garmin's agreements, log in, download the SDK and device files. +# # Interactive first-time setup behind `make sdk` (README section 15.3). # # 1. Garmin's agreements: you read them in your browser and accept them here. diff --git a/docker/scripts/sim-vnc.sh b/docker/scripts/sim-vnc.sh new file mode 100755 index 0000000..207db09 --- /dev/null +++ b/docker/scripts/sim-vnc.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Author: cirakg & claude ai +# Summary: Run the app in the simulator and show the simulator in your browser through noVNC. +# +# Usage: sim-vnc.sh DEVICE (the Makefile publishes port 6080 on 127.0.0.1 only) +# View: http://127.0.0.1:6080/vnc.html, then "Connect". +# +# The VNC server listens only inside the container (localhost, no password); +# the only way in is the noVNC web socket that the Makefile publishes on your +# machine's 127.0.0.1. Stop with Ctrl-C. +set -euo pipefail +# shellcheck source=ciq-env.sh +source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh" + +readonly VNC_PORT=5900 +readonly NOVNC_PORT=6080 + +device="${1:?usage: sim-vnc.sh DEVICE}" +ciq_require_sdk +ciq_require_key +build-app.sh debug "${device}" + +ciq_start_display +ciq_start_simulator +x11vnc -display "${DISPLAY}" -localhost -nopw -forever -shared -quiet -rfbport "${VNC_PORT}" \ + >/tmp/x11vnc.log 2>&1 & +websockify --web /usr/share/novnc "${NOVNC_PORT}" "localhost:${VNC_PORT}" >/tmp/websockify.log 2>&1 & + +cat >&2 <