Release, fleet, CI and observability operations for Anthias — balena cloud API + balenaOS track topology, CalVer version stamping and the release-cut/OTA sequencing, the Docker image builder + toolchain, CI-runs-on-merge-SHA behavior, host test isolation, Sentry conventions/diagnostics, and telemetry. Read before cutting a release, deploying to fleets, debugging CI, or triaging Sentry.
Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.
Quelldateien prüfen
Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
Ein direkter Befehl überspringt den Prüf-Prompt. Prüfen Sie die Quelle, bevor Sie ihn ausführen.
Release, fleet, CI and observability operations for Anthias — balena cloud API + balenaOS track topology, CalVer version stamping and the release-cut/OTA sequencing, the Docker image builder + toolchain, CI-runs-on-merge-SHA behavior, host test isolation, Sentry conventions/diagnostics, and telemetry. Read before cutting a release, deploying to fleets, debugging CI, or triaging Sentry.
Anthias release, fleet, CI & observability knowledge
Durable operational knowledge for shipping Anthias to the balena fleets and the
apt/ansible installs. Each bullet carries the mechanism plus the fixing PR/CalVer
where known; OPEN items are flagged. <org>/<fleet-slug> are the public
screenly / screenly_ose/anthias-* identifiers.
Balena cloud API (device mutation, pinning, OTA trigger)
Filtered bulk PATCH mutates the whole matching set in one call.PATCH /v7/device?$filter=<odata> with a JSON body applies that body to every matching device in one request (the whole matching set in seconds). Use for any fleet-wide cloud-side mutation (unpin, tags, notes); put exclusions inside the $filter. Validated clearing a large pin backlog (PR #2980).
Pin field trap: a device's pin is is_pinned_on__release. should_be_running__release is computed (pin if set, else the fleet's tracked release) — filtering it ne null matches the whole fleet, and the API rejects it as a PATCH body property. Supervisor pin/target is should_be_managed_by__release.
resinhup OS-HUP triggers need a descriptive User-Agent. The balena actions host that serves the raw cloud-API OS-update trigger sits behind a WAF that rejects the default Python-urllib/x.y User-Agent; set any descriptive User-Agent and the request goes through. api.balena-cloud.com itself does NOT need this. A trigger can still return a transient HTTP 500 {"error":"Device is busy: configuring"} — retry.
Log API caps at 1000 lines.GET /device/v2/<uuid>/logs?count=N returns at most 1000 lines regardless of N. On a device in a tight loop the whole window can be server/celery/redis chatter with ZERO viewer lines — classify such a device ANOMALY (logs saturated), not STILL-BROKEN. A standby device emits ~2 lines/5s so upgrade markers scroll out within minutes — use device DB state as ground truth, not just logs.
Mass remediation: deploy a self-healing release, do NOT script per-device SSH. balena's SSH gateway throttles hard under concurrency (~6 concurrent → minutes each; ~12 → all time out). A self-healing OTA reaches every device (including offline ones on reconnect) with no SSH. Reserve SSH for one-off diagnosis. CLI 25.x is balena device ssh <uuid> [service]; force -t, sleep before commands, pipe through sed 's/\r//g' (never redirect stdout to a file — it suppresses output).
Never print device identifiers from public-repo CI. Anthias is public → Actions logs are world-readable. balena uuids + online status + hourly timestamps are correlatable customer data. Any script a public workflow runs must default to aggregate-only output; per-device detail goes behind an explicit --verbose flag documented as local-only.
balenaOS track topology & fleet roller
Two tracks, mutually unreachable at their tops (verified 2026-06-03): Regular — latest 7.2.0 (pi1/3/4/5), 7.0.5 (x86); pi2 (raspberrypi2, ARMv7) is arch-capped at 5.1.20 with no 6.x/7.x and no ESR track. ESR (CalVer) — pi3/4/5 newest 2026.1.0 (next/pre-GA), 2025.7.0 = current/GA; x86 ESR current 2026.4.0. 7.2.0 and 2026.1.0 are each track-tops with no HUP target and cannot cross to the other track. Net: regular-7.2.0 and ESR-2026.1.0 pools can only be merged by reflash, not HUP.
Direction: standardize on ESR, GA/current (not next). ESR has long support windows suited to signage. Targets: pi1/3/4/5 → 2025.7.0; x86 → 2026.4.0; pi2 special-cased on regular 5.1.20 (no ESR). Devices already on 2026.1.0 (next) are ahead of GA and can't be pulled back (no downgrade HUP).
Disk image build (build-balena-disk-image.yaml) currently does balena os download --version latest = regular track; the ESR decision requires the per-device (current) ESR (balena os versions <type> --esr, pick the (current) line; menu-esr is interactive-only).
Fleet roller = bin/balena_fleet_maintenance.py — cautious staged HUP + release-unpin roller: dry-run by default, online-only, --percent tranches, resumable via ./.balena-maint-state.tsv. Add a same-track pre-filter before widening past the canary. Only online devices (a minority of the registered fleet) are HUP-able.
Fleet config.txt is IAC, not the dashboard. config.txt knobs live in balena-host-config.json at repo root, applied by the Apply fleet host configuration step (upsert each BALENA_HOST_CONFIG_<key> via balena env set, then prune vars not in the file). Audit doc: docs/balena-fleet-host-config.md. Malformed HOST_CONFIG values → supervisor reconcile/reboot loop (Reboot has been scheduled ... {} every ~0.5s, never converging). Issue #2947: fleet stored with literal quotes → firmware never loaded the overlay → eglfs_kms couldn't start.
Balena release cutting, version stamping & OTA deploy
CalVer YYYY.0M.MICRO — always check the current month. A new month resets MICRO to 0 (after 2026.05.2 the next is 2026.06.0, not 2026.05.3). Bump three files: package.json + pyproject.toml zero-padded (2026.06.0); uv.lock's name = "anthias" entry uses strict semver 2026.6.0 (month loses its leading zero). Git tag = v2026.06.0.
Balena renders CalVer as 0.0.0 unless normalized. balena reads version from balena.yml and requires strict 3-segment semver with no leading zeros. Fixed PR #2952: bin/render_balena_yml.sh <dest> <raw-version> normalizes (2026.05.1→2026.5.1) and stamps an unquotedversion: line. Re-deploying the same version is intended (balena auto-appends +rev1, +rev2…).
Release-cut sequencing (don't skip): publishing GitHub release v<ver> triggers build-balena-disk-image.yaml (release: published). Its preflight verifies every ghcr.io/<org>/anthias-{server,viewer,redis}:<short-hash>-<board> image already exists in GHCR; those are built by docker-build.yaml on the master push. So: merge → wait for the master Docker Image Build to finish (all boards; pi2/pi3 QEMU jobs finish last) → THEN publish the release. Cutting early fails preflight (guard catches it, no harm). Re-trigger with gh workflow run build-balena-disk-image.yaml -f tag=v<ver>.
balena-cloud-deploy jobs are the fleet OTA; balena-build-images builds bootable disk images. OTA can be green while the disk-image build fails — independent. deploy-balena-manual.yaml is the standalone "OTA-deploy an existing tag" hook.
Deploying master to fleets (no release): run bin/balena_ota_deploy.sh <board> <version> <short-hash> locally per board (pi2, pi3, pi3-64, pi4-64, pi5, x86, rockpi4). Sequence: version-bump PR → merge → wait for the bump commit's master Docker build (all boards) → preflight on all GHCR refs → run the script per board sequentially (~7–20 min each). Run from a checkout of the exact release commit. After an OTA there is still NO git tag / GH release / disk image — cutting the GH release later is a separate explicit step.
Docker image builder & toolchain
Validate Dockerfile/image changes with the real artifact, not throwaway containers. For any docker/Dockerfile.*.j2 or tools/image_builder/ change, run uv run python -m tools.image_builder --build-target <board> --service <svc> --disable-cache-mounts, then docker save | ssh device 'docker load' and run the actual container. Re-running apt install in a hand-stitched debian:trixie proves nothing (misses cache-mount semantics, multi-stage copies, board-specific Jinja2 conditionals).
No parallel image builds. Run tools.image_builder invocations sequentially — concurrent arm64 buildx-under-QEMU jobs thrash BuildKit and crash mid-build. Build → save+upload → deploy → validate → next board.
Userspace-only — no custom kernel/distro/Yocto. Anthias ships a userspace docker stack on top of whatever Debian/Armbian/Pi OS the operator installed. Kernel-side fixes not in upstream channels are out of scope — file upstream or degrade gracefully (reject at upload, drop a feature under memory pressure, log a breadcrumb). The +rpt1 ffmpeg repo (archive.raspberrypi.com) is acceptable (Raspberry Pi maintains it as a first-party userspace addition).
Linaro EOL froze all latest-<board> tags (RESOLVED #3070).build_qt5.sh fetched the Linaro gcc-7.4.1-2019.02 armhf cross-compiler from releases.linaro.org, which was retired → pi2/pi3 armhf jobs fail. docker-build.yaml's publish-latest mirrors onto the floating latest-<board> tag ONLY after the entire matrix is green → two dead 32-bit jobs froze all latest-*, stranding merged fixes for anyone tracking latest. Per-board pinned <hash>-<board> images still push on a red run. Fix #3070: install Debian crossbuild-essential-armhf (gcc 14, same prefix), symlink under the legacy /src/gcc-linaro-.../bin/ path the frozen Qt5 qmake.conf bakes into CROSS_COMPILE.
Diagnosing "latest still broken": read the code inside the image, don't compare digests (a fresh latest build ≠ the <hash> build byte-for-byte even from identical source). Use docker create + docker cp <path> + grep.
CI behavior & test infrastructure
PR test CI runs on the ephemeral merge SHA, not the branch head.pull_request workflows (Run Unit Tests / docker-test, mypy, Python Linter, OpenAPI Schema, CodeQL) run against refs/pull/<N>/merge. gh api .../commits/<HEAD>/check-runs shows only head-SHA checks (SonarCloud + copilot-pull-request-reviewer) and looks like the suite "didn't run". Find the real runs via gh run list --json name,headSha,event,conclusion,status and filter by name. Rapid pushes each spawn a fresh merge SHA + run set (no concurrency: cancel block); the newest merge SHA is the one that matters.
Host test isolation lives in the ROOT conftest.py (as of #2215): fake redis, gi/pydbus stubs, ENVIRONMENT=test. It used to live only in tests/conftest.py, which pytest does NOT load for src/anthias_server/api/tests/ — so running the API suite alone had no redis mock and every viewer-notifying view 500'd with ConnectionError to redis:6379. Two process-wide singletons in settings.py (ViewerPublisher, ReplyCollector) also leaked a real client across tests — the root conftest's _mock_redis autouse fixture seeds both with the per-test fake and clears them after. When a host-only 500 doesn't reproduce in CI, suspect these singletons or a connect_to_redis bypass; verify against the full CI-parity run (pytest -m "not integration").
E2E means the integrated stack, not a component harness. Don't tick a PR's "end-to-end test" box on a standalone module run. On PR #3004 a pi3 video helper passed standalone but the first true integrated run immediately exposed a real defect (a failing audio branch killed video wholesale). If a surface genuinely can't be covered, say so explicitly in the PR body instead of ticking the box.
Sentry conventions & diagnostics
Project: org/project slug anthias/anthias; issues named ANTHIAS-<n>. Error tracking shipped in 2026.6.2 (PR #3007) — "first seen" dates before that release are meaningless. Sentry release string = <CalVer>+<GIT_SHORT_HASH> (get_sentry_release); the fleet runs many releases at once.
API quirks:GET /api/0/organizations/ may 403 while /api/0/projects/ works (scope-dependent). Issue list uses ?query=is:unresolved; statsPeriod only accepts '', 24h, 14d. Mutations need an issue-write-scoped token. Resolve with {"status":"resolvedInNextRelease"}; rate limit 15 req/s (add sleep 1 in loops). The Sentry plugin's MCP server is not always connected in the dev-host session — fall back to the REST API.
resolvedInNextRelease ignores build metadata. Sentry picks the issue's highest-seen release as the reopen baseline and ignores semver +hash build metadata — an issue baselined at 2026.7.0 while the fleet is on 2026.7.1 can transiently REOPEN from un-upgraded devices until the fix ships.
environment tag vs baggage mismatch = test/CI stack leaking into prod Sentry, not a device failure. An event tagged environment=production whose Celery task headers carry baggage: sentry-environment=test means the producer ran in a test stack but the worker fell back to production defaults. Root cause (event in normalize_video_asset, "no such table: assets"): anthias-celery in docker-compose.test.yml lacked ENVIRONMENT=test/ANTHIAS_TEST_DB_PATH. settings.py defaults BOTH the DSN and the DB path off ENVIRONMENT, so any compose service missing the var silently becomes "production". Fixed #3013 by sharing the env block via a YAML anchor. When triaging, compare the event's environment tag against sentry-environment in the task baggage and the hostnames.
Device context tags (PR #3021 / #3025). Events are sent from inside containers, so Sentry's stock OS context never sees the host. #3021 tags device_type (build-time ENV), kernel_release/kernel_machine (containers share the host kernel), (, absent on x86). #3025 adds . Keep import-light — importing anthias_common.utils there crashes django-stubs' mypy plugin in CI's slim env, so is inlined. Segment webview-crash cohorts by (aarch64 → pi3-64 stream; armv7l → Qt5.15 WebEngine build).
Telemetry
GA4 device double-counting (root cause FIXED 2026-06-03). Telemetry (src/anthias_server/lib/telemetry.py, hourly beat) attributes each device by client_id = a random 15-char device_id stored ONLY in Redis; GA counts a new device whenever the id changes. Dockerfile.redis.j2 launched redis-server with NO config file, so dir defaulted to CWD (/) and RDB/AOF landed on the ephemeral writable layer instead of the mounted redis-data:/var/lib/redis volume → every container recreation wiped device_id. Fix = explicit --dir /var/lib/redis --appendonly yes --save "3600 1 300 100 60 10000".
OPEN telemetry findings (not fixed):device_type is always unknown (celery's environment: never exports DEVICE_TYPE); hardware_model is unknown on x86 (parse_cpu_info only matches exact-case Model, x86 uses lowercase model); identity is random, not anchored to stable hardware, so any Redis flush mints a new device; send_telemetry only catches RequestException but GA /mp/collect returns 2xx even for bad payloads. Stale docstring: the actual event name is device_active, not version.
Ops hygiene (testbeds)
When a testbed's df -h / is ~100%, check for core-dump accumulation first. A Pi 5 filled its 15 GB SD with ~1283 celery core dumps (core.<pid>, ~38 MB each, every ~5 min), crash-looping celery with No space left on device. With docker's containerd snapshotter the dumps were NOT under /var/lib/docker and NOT visible via docker exec — they lived at /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/<N>/fs/usr/src/app/core.*. Root cause fixed 2026-05-31: the get_display_power beat task ran _CEC_QUERY_SCRIPT as a python -c subprocess; on a Pi 5 with no usable CEC adapter, libcec's adapter thread aborts at interpreter exit (FATAL: exception not rethrown, SIGABRT — a C++/pthread teardown abort the try/except can't catch) and dumps a core. Fix: write the answer, flush, then os._exit(0) to skip Python/libcec teardown.
Website / marketing
website/assets/images/screenshots/*.png is gitignored — captures come from the marketing-screenshots.yaml workflow artifact, downloaded at build time (never committed, to avoid merge noise and stale snapshots). CI: deploy-website.yaml runs gh run download before hugo build. Local: bun run screenshots:fetch. Anything consuming the captures must assume they may be absent and provide a graceful fallback; never git add a .png under screenshots/.
dtoverlay="vc4-kms-v3d"
docker buildx imagetools inspect
<short-hash>-<board>
Transient disk-image failures → rerun, don't debug.balena os download --version latest intermittently dies with ERR_STREAM_PREMATURE_CLOSE (a CDN blip). gh run rerun <run-id> --failed fixes it.
Notes/publish flow:gh release create v<ver> --draft --generate-notes --title <ver> --target <FULL-sha> (short hash is rejected as target_commitish; draft avoids triggering deploy while editing), then gh release edit --notes-file, then gh release edit v<ver> --draft=false --latest.
P0: pre-rebrand balena upgrade wiped playlists (data unreferenced, not lost). Pre-rebrand devices store data at /data/.screenly/screenly.db + /data/screenly_assets/; the post-rebrand container reads /data/.anthias/anthias.db + /data/anthias_assets/. bin/migrate_legacy_paths.sh only ran on the apt/ansible host install, never on balena → start_server.sh found no anthias.db, ran a fresh migrate, and the viewer came up Playlist is empty. Detection gotcha:Playlist is empty / standby.png looks "healthy" to a naive crash-scan — a device that played before and is empty after IS the regression. Fixed 2026.06.0 (PR #2964): base-path-parameterized migration (adopt legacy db, symlink assets); idempotent, healed the affected devices.
pi4 config-reconcile reboot-loop (2026.05.2 incident, RESOLVED #2961). pi4 was the only board with a dtparam (i2c_arm=on,spi=on,audio=on) — balena's config.txt backend never converges on the comma-joined dtparam, so every pi4 supervisor thrashed into a reboot loop (the entire pi4 fleet; unpinned pi4 devices went offline). Updating the supervisor does NOT fix it. Fix: pi4 dtparam → single-value audio=on; i2c/spi are unused by a signage viewer.
docker-build.yaml runs on push to master ONLY, not on PRs — a build-system fix can't be validated by the PR's own CI; validate locally (branch name must have NO slash — it becomes a Docker tag).
bun blocks postinstall scripts by default.bun install (incl. -g) blocks npm lifecycle scripts — logs "Blocked N postinstalls" but does NOT fail. Packages shipping native binaries via postinstall break silently. Bit Anthias on bun install -g balena-cli@25.1.3 (@balena/compose-parser fetches a Go binary → balena deploy failed with Unexpected end of JSON input). Fix: targeted bun pm trust <pkg> (in $HOME/.bun/install/global); prefer targeted over --trust (all).
board_model
/proc/device-tree/model
balena: true|false
settings.py
is_balena_deploy()
kernel_machine
Triage rule: resolve every unresolved issue not seen on the current release (Sentry auto-reopens as regression if it recurs). Real code defects fixed in the 2026-07 sweep: PR #3180 (reconcile_stuck_processing bare time_limit=300 → SIGKILL trio), PR #3181 (unguarded await self.send() in asset_update consumer, disconnect-vs-broadcast race → narrow except RuntimeError), PR #3182 (before_send drop for client-disconnect Response content shorter than Content-Length static noise).