Skip to content

ci: watch for drift from firmware instead of finding it by accident - #129

Merged
openipc-ai merged 2 commits into
masterfrom
ci/detect-drift-from-firmware
Aug 25, 2026
Merged

ci: watch for drift from firmware instead of finding it by accident#129
openipc-ai merged 2 commits into
masterfrom
ci/detect-drift-from-firmware

Conversation

@openipc-ai

@openipc-ai openipc-ai commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Second of two PRs adding drift detection between this repository and OpenIPC/firmware. The other is OpenIPC/firmware#2313, which handles the excludes lists at build time.

Why

builder.sh copies devices/<item>/* over a fresh firmware clone, so a device directory that ships its own copy of a firmware file replaces it outright. A fleet-wide change in firmware reaches every device that only references a path and none that ships its own copy — silently, because nothing in either repository is looking.

One comment on firmware#2308 turned up three instances of that in an afternoon:

what drifted cost fixed by
13 board kernel configs missed the CONFIG_VT sweep 21 device builds still built the VT layer #126
97 defconfigs kept BR2_PACKAGE_JSONFILTER after firmware retired it 3 devices over their rootfs cap, red nightly #128
excludes lists name files that no longer exist 188KB of unused sensor libraries shipped firmware#2313

The trigger is the design

Every one of those was caused by a commit in firmware while builder sat untouched. A pull_request check here would never have fired for any of them — nobody opens a builder PR when firmware changes.

So firmware-drift.yml runs on a schedule (05:00, an hour after firmware's nightly so they don't race for a picture of the same HEAD) and files an issue rather than failing: drift someone else introduced is not a reason to redden a nightly that is otherwise fine. It reopens and re-bodies one issue rather than duplicating, so a week of unattended drift is one thread, not seven. Only the --self-test gates a merge here, out of lint.yml.

Two checks, because the two kinds of drift aren't the same shape

Shadowed files. Comparing content is useless — a builder copy is supposed to differ, that's why it exists. What matters is whether firmware's version moved since a human last reconciled them, so each entry pins the blob SHA it was reconciled against. Re-pinning is the act of looking again.

Shadows are discovered, not listed. builder.sh copies devices/<item>/* over the firmware clone, so any file at a path firmware also has replaces it — which makes same-path shadows findable rather than something to remember. discover_shadows() walks devices/ and reports anything sitting on a firmware path with no entry in the manifest, so the list cannot silently rot and a device added tomorrow cannot quietly introduce an unreconciled shadow.

The first version of this PR listed 13 board configs by hand. @qodo caught it omitting the device-local t21/t31 configs — correctly, and the hole was much bigger than the five it named: 93 same-path shadows across 35 firmware paths. The missing ones were not incidental. load_goke, load_hisilicon, load_sigmastar and four vendor .mk files are each shadowed by some device, and those are exactly the shared files a fleet-wide fix lands in — firmware regression-tests load_hisilicon's os_mem_size derivation, and two devices here ship their own copy that would not have seen it.

The manifest is now 101 entries: 88 discovered, plus 13 hand-authored ones discovery cannot find because the name differs from the file they replace (gk7205v200.generic-fpv shadows gk7205v200.generic, and nothing can infer that).

Defconfig symbols. Nothing here copies a firmware defconfig, so pinning doesn't apply. Every BR2_PACKAGE_*=y is resolved against buildroot, firmware and this tree, then sorted into resolves nowhere (a dead line kconfig ignores) and resolves, but no firmware defconfig selects it (needs an allowlist entry). An allowlist entry can also fence a symbol to the devices that need it — without that, writing JSONFILTER down as builder-only for devices/apfpv would equally bless it creeping back onto 95 unrelated defconfigs, which is #128 verbatim.

Replayed against all three regressions

Each reproduced by hand on a clean tree:

firmware moves a shadowed config
  -> br-ext-chip-goke/board/gk7205v200/gk7205v200.generic.config moved in firmware
     since this copy was reconciled (2026-08-25).
       builder copy: devices/common/.../gk7205v200.generic-fpv.config
       pinned 7450c6803f6d, firmware now f97dce84c0f8
       firmware commits since:
         85233f0e kernel: drop CONFIG_VT and the busybox applets that need it (#2308)

jsonfilter creeps back onto a non-apfpv device
  -> BR2_PACKAGE_JSONFILTER is allowlisted only for apfpv/*/configs/*_defconfig,
     but 1 other defconfig(s) select it.

firmware retires a symbol builder still selects
  -> BR2_PACKAGE_LIBUBOX is selected here but by no firmware defconfig.

Resolving against buildroot is not optional

Getting this wrong is how a checker lies, and mine did twice before it was right:

  • A first pass over OpenIPC packages alone called HOSTAPD, IW, PHP, UHTTPD, LIBZIP and BWM_NG dead. All six are upstream Buildroot packages — buildroot is vendored by neither repository.
  • Matching only Config.in then called PHP_EXT_ZIP dead, because buildroot declares php's extensions in package/php/Config.ext.

Both are false findings the checker invented rather than found. Without --buildroot that half is now skipped and says so:

note: no --buildroot given, so the dead-symbol half is skipped: upstream Buildroot
      packages cannot be told apart from symbols that resolve nowhere, and guessing
      produces false findings either way

Two symbols really are dead

BR2_PACKAGE_APFPV_GREG (the four *_apfpv_greg-generic-* devices) and BR2_PACKAGE_WIFIBROADCAST_EXT (devices/common, sitting next to WIFIBROADCAST_NG which does exist — probably a rename that left it behind) resolve to no Kconfig in buildroot, firmware or here. kconfig ignores those lines.

They are recorded as known_dead_symbols and reported as a notice on every run rather than removed. Whether those devices still want the packages is a maintainer's call, not a side effect of adding a check — and a notice keeps the question visible instead of blessing it.

Cost

The three new paths are classified in ci-matrix.py, so every future re-pin costs zero device builds:

$ echo .github/firmware-drift.json | python3 .github/scripts/ci-matrix.py --stdin
ci-matrix: 0/107 devices (needs_build=False) --- nothing that reaches a build

Without that, updating a pinned blob would be a 107-device run — and re-pinning is meant to be routine.

This PR itself still builds all 107, and should: it edits ci-matrix.py, which is deliberately absent from NO_BUILD_SCRIPTS because it is what decides the matrix and cannot be trusted to decide a smaller one for itself. Same reason #127 built everything.

$ git show --name-only --format= HEAD | python3 .github/scripts/ci-matrix.py --stdin
ci-matrix: 107/107 devices (needs_build=True) --- .github/scripts/ci-matrix.py affects every device

Test plan

  • python3 .github/scripts/check-firmware-drift.py --self-test — ok (13 shadowed files, 38 builder-only symbols, 2 known-dead)
  • full run against firmware master + buildroot 2024.02.10 — no drift, 2 notices
  • all three historical regressions replayed and caught, output above
  • runs without --buildroot and skips the half it cannot answer
  • python3 .github/scripts/ci-matrix.py --self-test — ok (107 devices, 15 smoke, 39 cases; was 36)
  • python3 .github/scripts/lint-workflow-shell.py — 31 run blocks parse clean
  • The scheduled job itself has not run — it cannot until this is on master. workflow_dispatch is on it for a first manual run.

builder.sh copies devices/<item>/* over a fresh firmware clone, so a device
directory that ships its own copy of a firmware file replaces it outright. A
fleet-wide change in firmware reaches every device that only references a path
and none that ships its own copy, silently, because nothing in either repository
is looking. One comment on OpenIPC/firmware#2308 turned up three instances of
that in an afternoon: 13 board configs that missed the CONFIG_VT sweep (#126),
97 defconfigs that kept BR2_PACKAGE_JSONFILTER after firmware retired it and put
three devices over their rootfs cap (#128), and excludes lists naming files that
no longer exist (firmware#2313 reports those at build time).

The trigger is the design. Every one of those was caused by a commit in
firmware while this repository sat untouched, so a pull_request check would
never have fired for any of them -- nobody opens a builder PR when firmware
changes. firmware-drift.yml runs on a schedule and files an issue rather than
failing: drift someone else introduced is not a reason to redden a nightly that
is otherwise fine. Only the self-test gates a merge here, out of lint.yml.

Two checks, because the two kinds of drift are not the same shape.

Shadowed files: comparing content is useless, a builder copy is *supposed* to
differ. What matters is whether firmware's version moved since a human last
reconciled them, so each of the 13 entries pins the blob it was reconciled
against. Re-pinning is the act of looking again. The mapping is hand-authored --
nothing can infer that gk7205v200.generic-fpv derives from gk7205v200.generic.

Defconfig symbols: nothing here copies a firmware defconfig, so pinning does not
apply. Every BR2_PACKAGE_*=y is resolved against buildroot, firmware and this
tree and sorted into "resolves nowhere" (a dead line kconfig ignores) and
"resolves, but no firmware defconfig selects it" (needs an allowlist entry). An
allowlist entry can also fence a symbol to the devices that need it; without
that, writing JSONFILTER down as builder-only for devices/apfpv would equally
bless it creeping back onto 95 unrelated defconfigs, which is #128 verbatim.

Replayed against the three regressions, each reproduced by hand on a clean tree:

    firmware moves a shadowed config
      -> br-ext-chip-goke/board/gk7205v200/gk7205v200.generic.config moved in
         firmware since this copy was reconciled (2026-08-25).
         builder copy: devices/common/.../gk7205v200.generic-fpv.config
         pinned 7450c6803f6d, firmware now f97dce84c0f8
         firmware commits since:
           85233f0e kernel: drop CONFIG_VT and the busybox applets ... (#2308)

    jsonfilter creeps back onto a non-apfpv device
      -> BR2_PACKAGE_JSONFILTER is allowlisted only for apfpv/*/configs/*_defconfig,
         but 1 other defconfig(s) select it.

    firmware retires a symbol builder still selects
      -> BR2_PACKAGE_LIBUBOX is selected here but by no firmware defconfig.

Resolving against buildroot is not optional and getting it wrong is how a
checker lies. Buildroot is vendored by neither repository, so a first pass over
OpenIPC packages alone called HOSTAPD, IW, PHP, UHTTPD, LIBZIP and BWM_NG dead;
all six are upstream. Matching only "Config.in" then called PHP_EXT_ZIP dead,
because buildroot declares php's extensions in package/php/Config.ext. Without
--buildroot that half is skipped and says so rather than inventing findings.

Two symbols really do resolve nowhere today -- BR2_PACKAGE_APFPV_GREG on the
four greg-generic devices and BR2_PACKAGE_WIFIBROADCAST_EXT in devices/common.
Both are recorded as known-dead and reported as a notice on every run rather
than removed: whether those devices still want the packages is a maintainer's
call, not a side effect of adding a check.

The three new paths are classified in ci-matrix.py so this PR and every future
re-pin cost no device builds. Without that a blob update is a 107-device run.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Detect and report drift from OpenIPC firmware

✨ Enhancement 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Detects shadowed files and stale Buildroot symbols before device configurations silently diverge.
• Schedules drift checks and maintains one actionable GitHub issue without failing nightlies.
• Self-tests drift metadata while exempting checker-only changes from device builds.
Diagram

graph TD
  S["Daily Schedule"] --> W["Drift Workflow"] --> C["Drift Checker"] --> I["Tracking Issue"]
  W --> F["Firmware Checkout"] --> C
  W --> B["Buildroot Kconfig"] --> C
  D["Drift Baseline"] --> C
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Firmware-triggered repository dispatch
  • ➕ Detects drift immediately after firmware changes
  • ➕ Avoids daily polling and timing assumptions
  • ➖ Requires cross-repository credentials and workflow coordination
  • ➖ Couples reliability to changes in the firmware repository
2. Fail the nightly build
  • ➕ Makes drift highly visible in existing CI
  • ➕ Avoids separate issue-management logic
  • ➖ Marks otherwise valid builds red for externally introduced drift
  • ➖ Provides poor ownership and can obscure genuine build failures

Recommendation: Keep the scheduled, issue-based design. It observes the repository where drift becomes actionable without requiring cross-repository credentials, and it preserves nightly build signal; repository dispatch is worth reconsidering only if firmware can provide a stable authenticated trigger.

Files changed (5) +669 / -2

Enhancement (1) +374 / -0
check-firmware-drift.pyAdd firmware and defconfig drift detection +374/-0

Add firmware and defconfig drift detection

• Introduces a checker for moved or deleted shadowed firmware files, stale package selections, unresolved Kconfig symbols, and obsolete baseline entries. It resolves symbols across Buildroot, firmware, and builder trees, and includes offline synthetic self-tests.

.github/scripts/check-firmware-drift.py

Tests (1) +8 / -0
lint.ymlGate drift checker integrity in pull requests +8/-0

Gate drift checker integrity in pull requests

• Runs the drift checker's self-test during linting so shipped shadow mappings and required metadata remain valid without cloning external repositories.

.github/workflows/lint.yml

Other (3) +287 / -2
firmware-drift.jsonDefine the reconciled firmware drift baseline +151/-0

Define the reconciled firmware drift baseline

• Adds pinned firmware blobs for 13 shadowed board configurations, documents 38 legitimate builder-only symbols, and records two known dead symbols. Device-scoped allowlisting prevents a legitimate exception from masking package creep elsewhere.

.github/firmware-drift.json

ci-matrix.pyExclude drift tooling from device build matrices +11/-2

Exclude drift tooling from device build matrices

• Classifies the drift workflow, checker, and baseline as non-build-affecting paths. Adds self-test cases proving these changes schedule zero device builds.

.github/scripts/ci-matrix.py

firmware-drift.ymlSchedule drift checks and issue reporting +125/-0

Schedule drift checks and issue reporting

• Adds a daily and manually dispatchable workflow that clones firmware, fetches the firmware-pinned Buildroot Kconfig files, and runs drift detection. Findings refresh or reopen one GitHub issue and are published to the job summary without failing the job.

.github/workflows/firmware-drift.yml

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 25, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Shadow list misses overrides ✓ Resolved 🐞 Bug ≡ Correctness
Description
The new shadows array omits the device-local t21.generic.config copies for
t21_lite_wansview-q5-1080p and t21_lite_xyx-06s, plus the t31.generic.config copies for three
T31 devices. Because check() examines only entries in this array, firmware changes to those
canonical configs will not be reported even though builder.sh overwrites them during affected
builds.
Code

.github/firmware-drift.json[R9-10]

+  "shadows": [
+    {
Evidence
builder.sh resolves the selected devices/ directory and recursively copies its contents over the
firmware checkout. The affected local and non-local defconfigs all reference the same canonical
T21/T31 paths, proving the local files replace firmware files rather than introducing unrelated
names; however, the checker loops exclusively over configured shadow entries, and none of these five
device-local paths appears in the added JSON array.

builder.sh[120-154]
devices/t21_lite_wansview-q5-1080p/br-ext-chip-ingenic/configs/t21_lite_wansview-q5-1080p_defconfig[17-23]
devices/t21_lite_chinamobile-hdc-51-a5-v12/br-ext-chip-ingenic/configs/t21_lite_chinamobile-hdc-51-a5-v12_defconfig[17-23]
devices/t31_lite_aceline-aip-o4/br-ext-chip-ingenic/configs/t31_lite_aceline-aip-o4_defconfig[17-23]
devices/t31_lite_aoni-ep01j05/br-ext-chip-ingenic/configs/t31_lite_aoni-ep01j05_defconfig[17-23]
.github/scripts/check-firmware-drift.py[153-179]
.github/firmware-drift.json[9-100]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The shadow mapping is incomplete, so copied T21/T31 kernel configs can drift from firmware without detection.
## Issue Context
`builder.sh` copies the selected device overlay into the firmware root. Add mappings and reconciled firmware blob SHAs for every device-local config that replaces an existing firmware config, including both T21 copies and all three T31 copies; also audit the remaining copied board configs for the same omission.
## Fix Focus Areas
- .github/firmware-drift.json[9-100]
- .github/scripts/check-firmware-drift.py[153-179]
- builder.sh[120-154]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can hide the parts of a finding you never read, like the evidence or the agent prompt

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread .github/firmware-drift.json
Qodo caught the shadow list omitting the device-local t21 and t31 kernel configs.
It was right, and the hole was much bigger than the five it named: a hand-written
list of 13 board configs against 93 same-path shadows covering 35 firmware paths.
The missing ones are not incidental -- load_goke, load_hisilicon, load_sigmastar
and four vendor .mk files are all shadowed by some device, and those are exactly
the shared files a fleet-wide fix lands in. firmware regression-tests
load_hisilicon's os_mem_size derivation; two devices here ship their own copy of
that script and would not have seen the fix.

Listing them by hand was the wrong shape. builder.sh copies devices/<item>/*
over the firmware clone, so any file at a path firmware also has replaces it --
which makes same-path shadows discoverable rather than something to remember.
discover_shadows() walks devices/ and reports anything sitting on a firmware
path with no entry in firmware-drift.json, so the list cannot silently rot and a
device added tomorrow cannot quietly introduce an unreconciled shadow.

The manifest goes from 13 entries to 101: the 88 discovered ones, plus the 13
hand-authored entries that discovery cannot find because the name differs from
the file they replace -- gk7205v200.generic-fpv shadows gk7205v200.generic, and
nothing can infer that.

Two self-test cases for the new path: a same-path shadow with no entry is
reported, and an entry silences it. Verified against the tree by dropping the
load_hisilicon entries, which surfaces both devices immediately.
@openipc-ai
openipc-ai force-pushed the ci/detect-drift-from-firmware branch from cf63374 to 6b5b677 Compare August 25, 2026 21:36
@openipc-ai
openipc-ai merged commit 6fef62c into master Aug 25, 2026
112 checks passed
@openipc-ai
openipc-ai deleted the ci/detect-drift-from-firmware branch August 25, 2026 22:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant