Follow-up to OpenIPC/firmware#2410, which left MT7601U as the only in-tree USB
Wi-Fi stack on hi3518ev300. RTL8188EU lands here as a camera profile, splitting
the two adapters across the two repositories.
hi3518ev300_lite_generic-rtl8188eu builds the driver INTO the kernel rather than
shipping it as a module: a profile whose only Wi-Fi is that driver has no reason
to carry it as loadable code, and the rootfs is the partition under pressure.
The out-of-tree rtl8188eus-openipc cannot make that move -- it is an external
kernel-module package, never linked into vmlinux -- so this uses the in-tree
staging driver at CONFIG_R8188EU=y through a variant kernel config, the same
shape as gk7205v200.generic-otg.config.
Built in, the driver also has no modprobe problem: as a module it was
unreachable, because /etc/wireless/usb's rtl8188eu-generic profile modprobes
'8188eu' while the in-tree module is 'r8188eu'. Linked into the kernel it binds
off its own USB device table at boot.
CONFIG_R8188EU selects WIRELESS_EXT, so the wext ABI firmware#2410 dropped comes
back on this variant only. It requests rtlwifi/rtl8188eufw.bin, so
linux-firmware-openipc comes with it. Registered in .github/firmware-drift.json
against the firmware blob it derives from.
MEASURED in CI, against the module-based version of the same profile:
uImage [1873KB/2048KB] -> [1991KB/2048KB] 56KB free
rootfs.squashfs [5084KB/5120KB] -> [4728KB/5120KB] 392KB free
Also drops excludes entries firmware now handles upstream: r8188eu.ko from
xiaomi-mjsxj02hl, and r8188eu.ko + scsi_mod.ko + sd_mod.ko from
yuancam-dgk423403. Both cameras build green; both run out-of-tree Realtek
drivers over nl80211 and are otherwise unaffected.
Not tested on a camera. This changes which 8188EU driver is used -- mainline
staging rather than the Realtek vendor fork -- so it wants confirmation that a
dongle is detected at boot without a modprobe, associates, and streams.
The first ssc337de_lite device in the tree to ship an excludes list: it prunes 17 of the 18 sensors OpenIPC/sensors builds for infinity6b0 and keeps sc401ai, the board's own.
Wi-Fi is an MT7601U on the vendor STA driver, so the device carries its own /etc/wireless/usb with the gpio 14 power cycle ahead of the modprobe, and execs modprobe so the profile's exit status is the module's rather than an unconditional success. The overlay is registered in .github/firmware-drift.json against the current firmware blob rather than left to drift silently.
Built green on CI before a rebase over #151 and #153, which collided only on the README device table.
* ci: check the setting paths shipped scripts hand to cli
`cli -s` cannot fail. yaml-cli stores whatever dotted path it is given,
creating intermediate mappings as it goes, and exits 0; majestic then silently
ignores a key it does not recognise. So a typo in a setting path applies
nothing and reports nothing, from the moment it is written to the end of the
device's life.
t40_lite_movols-mo-805p has been shipping six of them:
cli -s .video0.bitrate: 4000
cli -s .video0.rcMode: avbr
The path is `.video0.bitrate:`, not `.video0.bitrate`. Bitrate, rate-control
mode, profile, GOP size, GOP mode and OSD size have never applied on that
camera. The trailing colons are dropped here, including the one on the
commented-out `.onvif.enabled:`, which was a trap for whoever uncommented it.
The linter checks shape and nothing else. Whether a well-formed key is one a
given majestic build actually declares can only be answered by the running
binary -- it varies by vendor and by flavour -- while shape is decidable from
the tree, costs nothing, and is the whole of the class that shipped. A path
built at runtime is skipped rather than guessed at: uvc-gadget-setup reads
`cli -g ".$1"`, which is fine and unknowable here.
It carries a floor check for the same reason lint-workflow-shell.py does. A
matcher that quietly stops matching finds no bad paths, which looks exactly
like a clean tree -- so finding no cli invocation at all is itself a failure.
80 scripts scanned, 610 invocations, 6 bad paths, all in the one device.
* ci: recheck the merged result when the cli linter itself changes
lint.yml's push trigger names the checkers it guards one by one, and the new
one was not on the list -- so a master push touching only
.github/scripts/lint-cli-paths.py would have skipped the very job that exists
because #121 shipped a broken merged result nothing re-checked.
Same reasoning as the lint-workflow-shell.py entry beside it, and the same
cost: seconds, no runners.
* publish with an explicit release credential, and prove it before building
This repo was next in line for the failure OpenIPC/firmware hit on
2026-08-29. Moving the nightly off `schedule:` onto a dispatch changes
the identity the run executes as, and the default GITHUB_TOKEN's
release-write goes with it: a scheduled run executes as the repository's
scheduling identity, a dispatched one as whoever dispatched it, and for
an app/OAuth-backed identity the releases API answers 403 "Resource not
accessible by integration".
It does so even though the job declares `contents: write` and the run
log confirms the token was granted it, which is why nothing in the
workflow file looks wrong. firmware's run 33273333359 built 102 boards
over two hours before finding out. This repo builds 107 devices and
publishes the same way -- `gh release create` plus a PATCH of
refs/tags/latest -- so it would have failed identically the first time
the train reached it. It never got that far only because the train
correctly refused to advance past firmware's failure.
The publish step takes RELEASE_TOKEN instead of the default token, and
preflight proves that credential can create a release before the matrix
is allowed to spend hours on it, by creating a draft release and
deleting it: invisible, no tag, two API calls.
Asserting the secret is merely non-empty would not have caught the
firmware incident. The token that failed existed and was simply refused,
so the probe has to attempt the write.
* probe: survive re-runs and clean up after itself
Review caught a real defect in the credential probe. It deleted its
draft with a single fail-fast call, so an interrupted or cancelled job
left the draft behind -- and because github.run_id is stable across
re-runs, that leftover would collide with its own re-run and report a
perfectly good credential as broken. A guard that fails wrongly is worse
than no guard, because it blocks good runs and teaches people to ignore
it.
Three changes. The probe tag now carries run_attempt as well as run_id,
so a re-run can never collide with its own leftovers. Deletion moved
into a trap, so a cancelled job still cleans up. And a best-effort sweep
removes probe drafts stranded by earlier runs, because cleanup.yml does
not know this tag pattern and they would otherwise accumulate forever.
The sweep is capped at the 10 most recent releases and swallows every
error. That cap is not arbitrary: listing 100 releases in these repos
returns HTTP 504 because each carries hundreds of assets, measured at
~2s for 10 and ~7s for 30. Housekeeping must never be the thing that
fails a build.
Verified end to end against the live repo with a real token: creates the
draft, reports "release credential OK", the trap removes it, no tag is
created and no published release is touched. Both failure paths still
fail loudly -- unset token, and a credential the API refuses.
* probe: retry the write, and do not narrate garbage while sweeping
Review caught that the probe treated any one-shot POST failure as a bad
credential. GitHub answers 403 for secondary rate limiting as well as
for refusal -- the publish step retries six times for exactly that
reason -- and these repos return 504 on ordinary release listings often
enough that one was hit while writing this change. A blip would have
failed the nightly with "the token is wrong", sending whoever read it
off to reissue a credential that was never the problem.
The POST now retries three times with 5s and 10s backoff, and the error
says "after 3 attempts ... if this is not a transient API error". Three
rather than the publish step`s six because the value here is failing
fast: a genuine misconfiguration still surfaces in well under a minute
instead of after the whole matrix.
Testing the retry then exposed a second defect, in the sweep added by
the previous commit. gh writes its error body to STDOUT, so a failed
list feeds the cleanup loop lines of JSON instead of release ids, and
the log filled with `removing stranded probe draft {"status": "401"}`.
The deletes were harmless -- they simply failed -- but a step whose
entire purpose is to be believed must not narrate confident nonsense.
Only numeric ids are acted on now.
Verified against the live repo: happy path creates the draft, reports
OK, the trap removes it, nothing is left behind; a refused credential
retries three times and fails with the honest message; a failing list
produces zero bogus removal lines.
This is the last stage of a three-stage release train — widgetii/majestic
publishes the majestic tarball, OpenIPC/firmware builds the images, this builds
from that tree. Each stage was its own GitHub cron sized against a guess at the
previous stage's runtime: 17:25, 22:30 and 03:00 UTC.
From 2026-08-26 GitHub began delivering scheduled events hours late and
independently per repo, and this repository was the worst affected of the three
— +10h38m and +11h49m on successive nights, a 03:00 slot sitting in the deepest
part of the congestion band. On the 08-28 cycle that made this stage run FIRST,
at 14:49, ahead of both the repositories it is supposed to follow. It went
green, as it always would: no stage checks that its inputs are newer than the
ones it used last time.
The delay is not ours and not fixable here: for every late run
`created_at == run_started_at` to the second, so all of it sits upstream of run
creation, inside GitHub's dispatcher. Moving the cron off the top of the hour
and pushing a commit to resync the schedule were both tried on the majestic
side; neither changed anything.
The ordering moves to the host that runs majestic's self-hosted runners, which
dispatches each stage in turn and waits for it to succeed before starting the
next.
Unlike majestic and firmware this needed no gate change, and the preflight
comment now says why: those two skip when nothing has changed and keyed that
skip on the event name, which a dispatch would have made unreachable. This one
deliberately always builds, so there was no event-name condition to go stale.
The clone guard rail keeps its logic and loses half its argument: an inherited
workflow can no longer run nightly and unattended on a stranger's Actions bill,
but it can still do so on every internal PR.
Verified on this PR's own run rather than merged on the reasoning.
Across all 15 devices in the matrix:
```
Stage artifacts 15 success
Upload build artifacts 15 success
Send binary 15 skipped
Publish releases skipped
CI Gate success
```
15 `fw-*` artifacts produced, and the retention the fix was about is what
actually landed:
```
fw-hi3516ev300_ultimate_rvi-1ncmw2028
created 2026-08-27T21:17:00Z
expires 2026-09-03T21:16:58Z -> 7 days
```
So a pull request now keeps the images it built, for a week, while the two
paths that must stay closed to a PR -- the Telegram secret and shared release
assets -- are still closed.
Both devices have had a complete devices/<dir>/ in this tree for a long time and had
never once been built. #120 froze that state rather than changing it -- it put the 7
defconfigs that were in no master.yml matrix into NOT_BUILT so that a device falling out
of CI would be a test failure instead of a silent skip. Freezing was right for a change
whose job was to not alter what gets built; it was never meant to be permanent for
devices whose only problem is that nobody added the matrix line.
Two contributors did try to add that line. #111 (Tapo TC70 v3, July) and #108 (KC110,
June) both append to the device list in master.yml, which is where the registry lived
when they were written. It does not live there any more -- the matrix is read off the
tree -- so both PRs are merge conflicts now, and the devices they asked for are exactly
the two entries removed here. This is their CI half, landed the way the selector wants
it. #108 also carries a BR2_OPENIPC_MAJESTIC="ultimate" line and a vendor
libsns_ov2735.so for the no-video bug; those are unaffected and stay with that PR.
The two self-test cases that used t31_lite_tp-link-tapo-tc70-v3 as their example of an
unbuilt device move to t31_lite_xiaomi-mjsxj05hl, which is still in NOT_BUILT. The
overlay case also switches to a path that exists, since the device it now names has no
etc/inittab.
Neither device had ever been through a build, so this PR was where we found out. That
was affordable exactly once: a change to ci-matrix.py cannot narrow -- it is what does
the narrowing -- so this ran the full matrix and proved both in the same run. Both are
green:
Firmware (hi3518ev200_ultimate_tplink-kasa-kc110) SUCCESS
Firmware (t31_lite_tp-link-tapo-tc70-v3) SUCCESS
* ci: watch for drift from firmware instead of finding it by accident
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.
* ci: discover shadowed files instead of listing them by hand
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.
NOT_BUILT is documented as the opt-out that narrows to nothing, and a change to
one of those devices' defconfigs does exactly that. Anything else in the same
directory did the opposite: targets_in() only ever returns devices CI builds, so
a directory holding nothing but opted-out devices came back empty and fell
through to the "is in no directory CI builds" widen. Touching a device CI
deliberately skips cost a full 107-device run.
$ echo devices/gk7205v200_rubyfpv_generic/br-ext-chip-goke/board/\
gk7205v200/gk7205v200.generic-fpv.config | python3 .github/scripts/ci-matrix.py --stdin
ci-matrix: 107/107 devices (needs_build=True) --- ... is in no directory CI builds
$ echo devices/gk7205v200_rubyfpv_generic/br-ext-chip-goke/configs/\
gk7205v200_rubyfpv_generic_defconfig | python3 .github/scripts/ci-matrix.py --stdin
ci-matrix: 0/107 devices (needs_build=False) --- nothing that reaches a build
Same device, same opt-out, opposite answers. #126 touches three NOT_BUILT
devices and is building all 107 because of it.
The empty result was carrying two meanings: "every device here is opted out" and
"this directory is not one I know". Only the second should widen. devices_in()
answers the second question -- it reads directory_of, which is built from every
defconfig in the tree rather than the built subset -- so an unrecognised
directory still widens, which is the half of the rule worth keeping.
Three self-test cases, one per meaning: a kernel config and an overlay file in a
NOT_BUILT device reach nothing, and a devices/ directory with no defconfig at
all still widens. With this, #126's own file list narrows from 107 to 23.
* ci: publish the devices that did build when the matrix goes red
This morning's nightly built 97 of 98 devices. ssc333_lite_meari-speed-6s
failed, and that one device cost every device its release: there is no
nightly-20260824-* release, and `nightly` and `latest` still carry the
23rd's images. 2026-08-21 went the same way on two failures out of 98 --
no nightly-20260821-* either.
The publish job is documented as best-effort -- "publish whatever the
matrix produced even if some devices failed" -- and its condition already
lists 'failure' as an accepted matrix result. It never got that far. A job
whose `needs` failed is skipped by default, and an `if:` overrides that
only when it CONTAINS a status function; reading needs.buildroot.result
inside contains() does not count. So the job was skipped outright, never
evaluated, and reported "skipped" rather than anything red.
This has been true since #121 consolidated the release writes into a
single writer to stop the concurrent-upload rate limits. That change was
right -- before it each device uploaded from its own matrix job, so a
failing device took down only itself. Both nightlies that have failed
since then lost their whole release this way.
`!cancelled()` rather than `always()`: the contains() already drops a
cancelled or skipped matrix, but a run cancelled after a green matrix
would still start uploading under always(), and a half-written nightly is
what the single-writer design exists to avoid.
The gate stays red on a partial matrix; that is unchanged. What changes is
that a red run now still delivers the images it built. Devices that failed
keep their previous image on nightly/latest, which the ci-gate already
assumed; the new warning in Collect assets says so on the run rather than
leaving it to be inferred.
Same fix as OpenIPC/firmware#2303 -- the two publish jobs share a common
ancestor and carried the identical gate.
Verification: master.yml parses; lint-workflow-shell.py checks 23 run
blocks clean (and its own self-test passes); ci-matrix.py --self-test ok.
Publish condition audited against every path -- PR, no-op schedule, clone
guard, cancelled run, failed select and all-devices-failed all still skip;
only "matrix failed, some devices built" changes from skip to publish.
* ci: finish the sentence in the partial-publish warning
The line ended on "for every device that did not", leaving the reader to
supply "build" from the clause before it. It is an operator-facing log
line on the one run where somebody is trying to work out what shipped, so
say it in full and point at where the failed devices are listed. builder
has no Build summary job, so the run's own job list is that place.
Raised by Qodo on #125.
Two bugs in the linter #122 added, found by Qodo reviewing the port of
it to OpenIPC/firmware#2290 and confirmed against this copy rather than
taken on trust.
walk() collected any mapping with a scalar `run` key, anywhere in the
document. That is one key name away from linting things that are not
shell: an action input, an env var or a matrix field called `run` holds
arbitrary text, and feeding it to bash -n fails a workflow that is fine.
On a fixture with one real step plus an `env: run:` and a `with: run:`,
this copy collects three blocks. Steps now have to come out of a
`steps:` sequence, which is the actual schema invariant and still not a
hardcoded jobs.*.steps[*] path, so composite actions (`runs: steps:`)
keep working.
The expression substitution used `\$\{\{.*?\}\}`, which stops at the
first `}}` even when it is inside a string literal. On
x=${{ fromJSON('{"a": {"b": 1}}') }}
this copy produces `x=__GHA_EXPR__') }}` and bash -n then reports an
unbalanced quote -- a false failure on a valid block, in the direction
that trains people to ignore the job. Replaced with a scanner that
tracks GitHub's single-quoted strings, including the doubled '' escape,
and returns the text untouched if an expression is never closed.
Both are latent here: nothing in this repo has a `run` key outside a
step or a `}}` inside an expression string, and the block count is
unchanged at 23. They are fixed because the failure mode is a red job
with nothing wrong with the tree, which is how a check stops being
believed.
Self-tests for both. The docstring described the behaviour this
replaces, so it is corrected too, and the now-unused `re` import drops.
Keeps this copy identical to firmware's apart from the incident
paragraph and the block floor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unconditional `apt-get update` added to satisfy review on #122 was
fine on the PR runs (~5s) and then sat for six minutes on the first
master push, still in progress when it was cancelled. Nothing was wrong
with the tree; apt was just slow.
That is a bad trade for this job in particular. Its argument for
existing -- and for carrying a push trigger at all -- is that it answers
in seconds, and it was made to depend on a network fetch it does not
normally need. ubuntu-latest ships PyYAML.
So import first and install only if that fails. The dependency is still
handled rather than assumed, which was the point of the review finding,
but a working runner pays nothing for it. `if` rather than
`python3 -c 'import yaml' && exit 0`, because under `bash -e` the latter
fails the step on the branch where the import fails -- exactly when the
install needs to run. Both paths checked under -e.
Also timeout-minutes: 10. The default is six hours, which is how a step
that hangs rather than fails occupies a runner and tells nobody.
The whole job now completes in three seconds, and selects 0 devices
rather than 107 because #122 classified lint.yml as no-build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Collect guard added to the publish job in #121 shipped without its
closing `fi`. Run 32096242353 -- the first nightly to reach the new
single publish job -- built all 107 devices, staged 219 assets, and then
died in `bash -e` with
line 25: syntax error: unexpected end of file from `if' command on line 14
before writing anything. No nightly-20260818-* release exists, `nightly`
and `latest` still carry the 17th's images, and ci-gate went red. The
gate did its job; there was simply nothing left to publish by then.
Nothing caught it earlier because the publish job carries
`if: github.event_name != 'pull_request'` -- there are no artifacts to
publish on a PR, so the entire path is unreachable from PR CI by
construction. Both of #121's runs were green without ever executing the
step. #121 said as much ("none of this path can run on a pull request")
and checked the four states offline, but offline-checking the logic does
not check that the script parses, and that is what broke.
So the one-line fix, plus the check that would have caught it in seconds
without a runner: lint-workflow-shell.py parses every `run:` block in
.github/workflows/ with `bash -n`, and lint.yml runs it on PRs and on
master pushes that touch either.
Notes on the checker:
- ${{ }} is not shell, so each expression is replaced with a plain word
first. That is a real limitation -- an expression interpolating shell
syntax is checked as the word, not as what it expands to -- and the
substitution preserves line counts so reported lines still point at
the right line. --self-test asserts an unterminated `if` containing an
expression is still rejected while the closed form passes, because the
way this breaks is by making everything pass.
- Steps are found by walking for any mapping with a `run:` key rather
than by the jobs.*.steps[*] path, so nothing depends on guessing where
they live, and a discovery floor fails the run if the walk stops
finding them. A checker that silently checks nothing looks exactly
like a clean tree.
- Syntax only. It says nothing about quoting or `-e` semantics; a
shellcheck pass wants per-block disables for the substitution above
and is a separate change. actionlint would cover more and is worth
considering later; keeping this narrow is what makes it noise-free now.
ci-matrix.py classifies both new files as no-build. They were names it
had never heard of, and unknown widens by design, so the first push
queued all 107 devices to prove a one-line YAML fix -- #121, which
touched only master.yml, ran 19. This PR still took the full matrix
because it edits ci-matrix.py itself, which always widens; the selector
is not trusted to pick a smaller matrix for its own changes.
PyYAML is installed explicitly rather than inherited from the runner
image, and the workflow declares `contents: read` rather than taking the
repo default. Both from review; neither would have failed the job today.
Verified: the checker flags the merged master.yml with the same message
the runner gave, passes the fixed tree, and all five guard states behave
as #121 intended -- normal night publishes, a broken download and a
green-but-empty dist both fail, and a dead matrix with nothing built
still exits clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ci: write releases from one paced job, not from 107 at once
Every matrix job uploaded its images to three shared releases with
softprops/action-gh-release, so a nightly fired on the order of 400
concurrent asset writes at `nightly`, `latest` and the dated tag.
OpenIPC/firmware ran exactly this design and the releases API answered
HTTP 500 under the contention -- run 27108181857 built every board and
still went red on 14 jobs, all of them in their upload step. Firmware
then hit the sequel: consolidating into one job but keeping concurrent
uploads tripped the per-actor secondary rate limit at 350 of 393 assets.
This lands where that ended up, without the two failures in between.
Matrix jobs now stage what they produced into dist/ and hand it to
actions/upload-artifact. A single publish job downloads all of it and
drives the release writes with gh: one asset at a time, paced ~1/s under
the per-minute mutation ceiling, with exponential backoff so a transient
403/429 is retried rather than failing the nightly. The single-writer
property is what fixes the 500s; the pacing is what keeps the fix from
tripping the other limit.
The dated release keeps the full asset set, because enrich_manifest.py
only ever reads dated releases. `nightly` and `latest` are delivery
aliases for flashers and now get images only: re-uploading the size
sidecars to two more tags is pure rate-limit cost, and nothing reads
them from those tags.
Staging copies whichever of the two naming schemes the build used --
compound devices rename to <device>-<flash>.tgz in the workspace root,
single-underscore ones leave openipc.<soc>-<flash>-<variant>.tgz under
openipc/output/images/ -- so asset names are byte-identical to today.
Checked both against enrich_manifest.py's COMPOUND_RE and SIMPLE_RE.
ci-gate covers publish, so a failed release write fails the run instead
of passing quietly.
The staging loop uses `if` rather than a trailing `&&` chain: the step
runs under `bash -e`, where a chain that tests false is only harmless
because something after it happens to reset the status.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ci: stop the publish job succeeding without publishing
Two High findings on #121, both real.
The download step carried continue-on-error: true, so a transient or real
artifact-download failure produced an empty dist/, the publish step's
count guard skipped it, the job went green, and ci-gate accepts
publish=success. A nightly could silently write nothing while the run
stayed green.
The flag stays -- it exists so a matrix where every device failed, and
so uploaded no fw-* artifact at all, does not hard-fail before the gate
can report why -- but Collect now turns it back into a failure for every
other case: if the matrix went green, a download that did not succeed is
an error, and zero collected assets is an error. Only "matrix failed and
produced nothing" is still tolerated, and the gate fails that run on the
matrix result anyway.
The second finding said count could read " 0" because wc pads. On
GNU coreutils, which is what the runner has, it does not -- that is BSD
behaviour, and `printf '' | wc -l` here gives an unpadded 0. Normalised
anyway, since it costs a tr and stops the guard depending on which wc is
in front of it.
Chasing it did surface a worse hole it sits next to. dist/ can hold
sidecars and no images -- one device that produced a size report and no
.tgz is enough -- and count is then 1, so publish ran, force-moved the
nightly and latest tag refs, and uploaded zero images. The two tags users
flash from would point at a build with no firmware behind them. Both tags
now move only when there is at least one image to move them to;
otherwise they stay on yesterday's, with a warning.
Checked all four states offline, since none of this path can run on a
pull request: green matrix with a broken download, green matrix with an
empty dist, failed matrix with nothing produced, and a normal night. The
first two now fail and used to pass.
Note the same continue-on-error and unnormalised wc are in
OpenIPC/firmware's publish job, which is where this one came from.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ci: build pull requests, and only the devices they reach
This repo had no pull_request trigger at all. A change landed unbuilt,
and the nightly found out the next morning -- or a user did. Turning PR
CI on meant 107 device builds per push, which is why it stayed off.
Almost every change here is one device: 639 of the file touches in the
last 200 commits sit under a single devices/<dir>/, and the median
commit reaches 2 devices. So narrowing first is what makes PR CI
affordable at all. .github/scripts/ci-matrix.py maps the paths a PR
touches to the devices that build them, and master.yml takes its matrix
from it.
The mapping is not written down, it is read off the tree the same way
builder.sh reads it. builder.sh line 121 locates a device by its
defconfig and copies that WHOLE devices/<dir>/ tree over the firmware
clone, so the devices a file affects are exactly the devices whose
defconfig shares its directory. That distinction is load-bearing:
devices/common/ backs 18 targets and devices/apfpv/ backs 2, and
treating a directory as one device would skip 17 real builds. A
defconfig still names one target even inside a shared directory.
THE NIGHTLY IS NOT NARROWED. builder.sh re-clones OpenIPC/firmware at
HEAD on every run, so what a nightly builds is decided mostly outside
this repo; master.yml already carries the scar of an earlier gate that
skipped on this repo's HEAD and left upstream fixes invisible to users.
Every event except pull_request gets the whole matrix, and --self-test
asserts that for schedule, workflow_dispatch and push.
Nothing on a pull_request may write a release. The three upload steps
were gated only on an image existing, so with a pull_request trigger
added they would have clobbered the published nightly and latest assets
that users flash. They and the Telegram post are now guarded on the
event.
master.yml and check-adjacent plumbing get a 15-device smoke set rather
than all 107: they decide how a build runs, not what it produces.
--self-test enforces the cover -- every vendor, architecture, toolchain
tuple and variant, plus both shared device directories -- rather than
the list, so the set can be re-picked while the property holds. It
already earned itself: the first run rejected my hand-picked set for
missing arm-openipc-linux-gnueabi, which exactly one target in the tree
has, and musleabihf.
Also adds the CI Gate umbrella check, since a dynamic matrix cannot be
named in branch protection and nothing reported a verdict before; and
freezes the 7 defconfigs that exist but are in no matrix, so a device
falling out of CI is a test failure rather than a silent skip.
Replaying the last 200 commits: 20330 device-builds if every PR built
everything, 5930 with the selector, 70.8% less, no coverage leaks. 43
of the 49 that still widen do so on paths that no longer exist
(devices/ssc338q_apfpv/ before it became devices/apfpv/, the old
top-level common/, a deleted builder-local.sh); restricted to commits
whose paths survive, it is 78.2%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ci: derive the device list instead of writing it down
Adding a camera IS the work in this repo: 8 of the 10 PRs open right now
add a device, and 14% of recent commits do. With the list written down
in ci-matrix.py, every one of those PRs also had to edit that file --
and a change to that file cannot narrow, because it is what does the
narrowing. So registering one camera cost a full 107-device build to
prove one device. The most common change got the worst outcome.
The matrix is now every defconfig under devices/, minus a NOT_BUILT
opt-out that keeps the 7 already in the tree but out of CI. Adding a
device is creating its directory, nothing else, and it builds exactly
that device.
This is the opposite call to OpenIPC/firmware's ci-matrix.py, on
purpose. There the matrix is curated -- 29 defconfigs are deliberately
never built and boards are not the product -- so an explicit list is the
honest description and an opt-out list would be noise. Here devices are
the product and the list is just "all of them".
The derived matrix is identical to the 107 master.yml carried: nothing
lost, nothing gained. --self-test now checks the opt-outs still name
real defconfigs, so a rename cannot leave a device silently building
under its new name while its old name sits in NOT_BUILT.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ci-matrix: own $GITHUB_OUTPUT instead of being redirected into it
The docstring said the default mode writes $GITHUB_OUTPUT; it printed
key=value lines to stdout and left the workflow to redirect them there.
Raised in review on #120.
Fixing the prose would have made the file honest, but the redirect
itself is the weaker half. Under `>> ${GITHUB_OUTPUT}` every print() in
this file is one keystroke away from corrupting the step outputs, and a
crash between the first line and the last leaves a half-written file
that Actions still reads. That the diagnostics go to stderr is what
keeps it safe today -- a rule that has to hold forever, rather than a
property of the code.
So the script appends the three lines itself when $GITHUB_OUTPUT is set,
and prints them when it is not, so a local run still shows its decision.
--stdin never touches the file: it is the mode for reading with your
eyes. master.yml drops the redirect, though leaving one in place would
have been harmless -- with the file written directly, stdout is empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A clone of this repo pushed to a new repository -- a private mirror, an
internal re-host -- inherits .github/workflows together with its cron.
The full platform matrix then runs on that owner's Actions bill, nightly
and unattended. Forks are only partially covered: GitHub disables
scheduled workflows there, but nothing covers a fresh repository, and
nothing warns the owner.
This mirrors OpenIPC/firmware#2255, where the same inherited cron was
measured at 97 jobs and 2,146 billed minutes from a single unattended
night, and never converged: publishing failed on the mirror, so the sha
skip-gate could never engage and the matrix rebuilt in full every night.
The rule: unattended runs happen only on the canonical repo. Anywhere
else a workflow runs only if someone explicitly asked for it
(workflow_dispatch), or if it is both free and attended (pull_request on
a public repo). This is deliberately stricter than gating purely on cost
-- a public re-host's minutes are free and its cron is still skipped,
because a re-host quietly publishing nightly releases under its own name
is a worse outcome than wasted minutes.
master carries the guard on preflight; buildroot needs it and inherits
the skip. manifest gates its cron-originated workflow_run path the same
way, because a nightly whose jobs all skipped still completes and fires
workflow_run. cleanup carries it on its weekly cron. build-one is
dispatch-only and needs nothing.
The pull_request clause is unreachable here today -- no workflow in this
repo has a pull_request trigger -- and is kept on purpose: it is the
clause that lets contributor PRs run, and omitting it would mean adding
a pull_request trigger later silently skips CI on every fork. That
over-block was the first thing reviewers caught upstream.
Upstream impact is none: on OpenIPC/builder the first disjunct is true,
so every added condition is a tautology and each job's if reduces to
what it was before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds gk7205v200_otg_generic, a firmware profile that puts the single DWC3
controller into dual-role so the board can either consume a USB webcam
(existing usbcam path, host role) or present itself to a PC as one (UVC
gadget, device role). The roles are mutually exclusive -- xHCI HCSPARAMS1
reports MaxPorts = 1 -- but usb-mode switches between them at runtime, so
one image covers both and no reboot is needed.
Kernel side, three patches:
0015 sets dr_mode = "otg" in the DTS so dwc3 registers both an xHCI host
and a UDC.
0016 makes device mode actually work on a part that has only ever run as
a host. Three fixes: re-run dwc3_event_buffers_setup() on gadget start
(an xHCI reset wipes GEVNTADR), soft-reset the core before taking the
lock so the PHY is re-initialised (without it high-speed chirp fails),
and swallow the run_stop timeout on pullup(0), which otherwise
permanently poisons cdev->deactivations.
0017 disables the vendor's UVC_SG_REQ path. It hands a whole frame to the
UDC as one scatter-gather request; dwc3 chains those sg entries into a
single isochronous service interval, which on a full-speed link is one
1023-byte packet, so every frame larger than that reached the host
truncated and H.264 decoders reported "bytestream -5" on roughly half the
frames. Goke never shipped this path enabled -- CONFIG_USB_CONFIGFS_F_UVC
is unset in every vendor defconfig, including gk7605v100's, and the SDK
carries no userspace UVC sample -- so it was never exercised. The
mainline path (32 plain one-packet requests) is used instead.
Userspace side: usb-mode switches roles with the unbind/flip/bind
sequencing dwc3_set_mode() does not do on its own, uvc-gadget-setup
composes the configfs gadget, and S72usbmode forces a concrete role at
boot -- not optional, since dr_mode = "otg" leaves GCTL.PRTCAPDIR in a
state this 4.9 dwc3 never moves off by itself.
The descriptors advertise exactly the one format, frame size and interval
majestic is configured to serve, read from /etc/majestic.yaml. majestic
answers every PROBE/COMMIT with its own bFormatIndex/bFrameIndex because
it has one stream and cannot follow the host, so anything extra is an
invitation to select something that will not arrive: asking for MJPEG got
raw H.264 (an SPS where a JPEG SOI belongs, garbage to every consumer),
and asking for 1080p got 720p.
Profile drops the air-unit packages (wifibroadcast, rtl8812au, mavfwd,
mavlink-router, vtund, linux-firmware, wireless-tools) and the whole
wireless kernel stack, which pays for usb_f_uvc.ko and for keeping the
full sensor list -- the fpv trim to IMX307/IMX335 is keyed on
OPENIPC_VARIANT="fpv", and a general-purpose USB webcam should not be
sensor-locked.
Measured on a gk7205v200 lab board over a full-speed link: H.264
1280x720 at 30 fps with zero decoder errors and no dropped buffers, and
MJPEG delivering valid JPEGs at the jpeg channel's own rate.
Requires a majestic built with ENABLE_UVC_GADGET to feed the gadget;
that is a separate change.
PR #105 added a "Refresh moving-ref package downloads" step that drops
*-{HEAD,master,main,dist}.tar.gz before each build so buildroot re-fetches
the current ref. That fixed the majestic-webui-dist.tar.gz staleness (the
*-dist.tar.gz case), but it never matched majestic itself.
majestic downloads from S3 as majestic.<family>.<variant>.master.tar.bz2
(majestic.mk) — a DOT separator, .tar.bz2, and an S3 host the old pattern
deliberately spared as "content-addressed". But that S3 object is a moving
`master` build, re-uploaded on every majestic CI run, with no .hash to force
a refetch. So it stayed frozen in the monthly builder-dl cache while the
webui refreshed — the exact desync that shipped a pre-x-groups majestic in a
t31 lite image against the current webui, blanking the settings page with
"No settings groups in schema."
Broaden the eviction to a regex covering both -ref and .ref separators across
gz/bz2/xz, so majestic.*.master.tar.bz2 and majestic-webui-dist.tar.gz are
both dropped while semver/SHA-pinned tarballs keep their cache.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dahua-OEM 2 MP camera. The SC223A is on hardware I2C0 routed to the
VI-RAW pads (0x112c0060/0x64 func2) rather than the default mipi-sensor
pads (0x30/0x34 func1) the SDK probes, and needs a 27 MHz MCLK
(0x120100f0=0x19; SDK default 0x11/div4 leaves it unresponsive).
S71devmem re-muxes the pads, selects the SENSOR_RSTN reset (GPIO8_7
func2) and fixes MCLK after S70vendor/load_goke; the stock
open_sensor_i2c then reaches the sensor over /dev/i2c-0. No kernel
module needed.
Verified on hardware: chip-id 0xcb3e over HW I2C0, MIPI lock 1920x1080
zero errors, majestic streams correct colour 1080p.
Co-authored-by: build <build@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
builder caches BR2_DL_DIR (/tmp/builder-dl) under a monthly key (date +%m), and
actions/cache only saves on a key miss, so the first build of the month freezes it for
weeks. Packages pinned to a moving ref (VERSION = HEAD/branch, or the majestic-webui
`dist` release asset) download as a constant <pkg>-<ref>.tar.gz, so buildroot keeps
reusing the stale tarball — defeating the clean firmware-HEAD re-clone builder does for
freshness.
Empirically, a 2026-06-14 builder nightly shipped a ~2-week-old majestic-webui (full
bootstrap.min.css, no mj-settings.js) while the firmware nightly of the same day shipped
the current slim UI. Drop *-{HEAD,master,main,dist}.tar.gz before each build (master.yml
+ build-one.yml) so buildroot re-fetches the current ref. Mirrors the same fix in
OpenIPC/firmware.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Mirror of OpenIPC/firmware#2169. PR #102 extended enrich_manifest.py
with SIZES_COMPOUND_RE + SIZES_SIMPLE_RE + parse_sizes() so the public
manifest carried `platforms[<plat>].sizes.url` and `@sizes` lines
pointing at each release's sizes.json asset. The intent was a
browser-side consumer; the GitHub release-asset URLs return no
Access-Control-Allow-Origin header, so every browser fetch was blocked
by CORS.
This commit reverts only the manifest-indexing half of #102. The
release-asset upload itself stays in master.yml — server-side consumers
can still read the JSON via `gh release download` or any CI workflow.
Only the in-manifest URL is removed, because that URL only made sense
for an in-browser reader that turns out can't reach it.
The resulting enrich_manifest.py is bit-for-bit identical to the
pre-#102 state.
Co-authored-by: John <zekccsripfwrocp-15689@yn.furvionx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Firmware PR #2166 added a `size-report` Makefile target + the
`general/scripts/size_report.py` analyser that emits a per-package /
per-kernel-module `sizes.<soc>-<variant>.json` next to the .tgz. Builder
clones firmware fresh on every run and shells to `make BOARD=...`, so the
script + target are already available downstream — this PR adds the CI wiring
and manifest extension so the JSON ships as a release asset and surfaces
through https://openipc.github.io/builder/manifest.json.
Three changes:
* `builder.sh`: best-effort `make size-report` after the main build, and
copy `sizes.*.json` into the timestamped archive next to `.tgz` /
`rootfs.squashfs.*`. Failure is swallowed so a legacy `OPENIPC_FW_REV` pin
to a pre-#2166 firmware ref can't sink the build.
* `.github/workflows/master.yml`: new `Build size report` step after the
existing `Build firmware`. Mirrors the bimodal rename trick the .tgz path
already does — compound matrix entries (`<soc>_<variant>_<vendor>-<model>`)
share `<soc>_<variant>` and would otherwise collide on the firmware-side
`sizes.<soc>-<variant>.json` filename when uploaded to the same release
tag. Compound entries get the JSON renamed to `sizes.<NAME>.json`; simple
entries keep `sizes.<soc>-<variant>.json`. `${{env.SIZES}}` is added to
all three upload `files:` blocks (dated / nightly / latest).
* `.github/scripts/enrich_manifest.py`: `SIZES_COMPOUND_RE` +
`SIZES_SIMPLE_RE`, a `parse_sizes()` helper that tries compound first
(because `[^._-]+` in the simple soc class would otherwise accept the
compound form too), the sidecar is recorded at the platform level under
a `"sizes"` key in `manifest.json`, and a new `@sizes <build_id>
<platform> <size> <url>` line type is emitted in `manifest.flat`
alongside the existing `@channel` directive.
Schema is unchanged for existing consumers — on-device sysupgrade reads
positional 4-column `manifest.flat` lines and stays inert on unknown
`@`-prefixed directives.
Co-authored-by: John <zekccsripfwrocp-15689@yn.furvionx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors OpenIPC/firmware#2133. Today's scheduled cleanup run failed
with HTTP 403: Resource not accessible by integration when dispatching
manifest.yml — the prune job only declared contents:write. The refresh
step also fires on the empty-prune path (exit 0 only exits the step,
not the job), which is wasted work since manifest.yml is already kept
fresh by master.yml's workflow_run.
Add actions:write to the permissions block, and gate the refresh on a
step output so it runs only after an actual deletion.
Co-authored-by: John <zekccsripfwrocp-15689@yn.furvionx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The preflight gate skipped scheduled runs whenever this repo's HEAD
matched the SHA recorded in the previous nightly release body. That key
ignores every upstream input the build actually depends on:
OpenIPC/firmware HEAD, the toolchain release, the kernel tarball, the
vendor osdrv packages, and individual buildroot package upstreams.
builder.sh re-clones OpenIPC/firmware fresh on every run, so those
inputs do move between nightlies even when this repo is quiet.
The concrete failure mode this caused: on 2026-05-26 OpenIPC/firmware
bumped its hisilicon-opensdk pin to b958db4, which contains the V4
mipi_rx vsync IRQ fix (openhisilicon PR #195, closes#194 — torn
frames / 'Timeout from venc channel 0' on hi3516ev200, gk7205v200,
gk7205v210, gk7205v300, hi3516ev300 hardware). The next builder
nightly (2026-05-27 07:00 UTC) ran with this repo unchanged since
2026-05-25, so preflight skipped the entire Firmware matrix. Field
cameras (verified: gk7205v210_lite_vixand-ivg-g4h at IP redacted,
running BUILD_ID nightly-20260526-ee40da8, srcversion
8EF923DF907C1F7F3C6898E — the pre-fix open_mipi_rx fingerprint from
PR #195's verification table) kept pulling the stale 'latest' release
asset and kept showing the regression after sysupgrade.
A cross-input dedupe key would be possible (firmware HEAD + toolchain
URL hash + ...) but enumerates every upstream this build pulls. Simpler
and correct: scheduled nightlies always build. workflow_dispatch is
unaffected — that path was already unconditional.
Co-authored-by: Dmitry Ilyin <zekccsripfwrocp-15689@yn.furvionx.com>
OpenIPC/firmware/.github/workflows/build.yml has a `output/dl` cache
step that mirrors the Buildroot download directory across runs. This
saves both runner-minutes and — importantly — masks transient upstream
mirror failures: once any cron has cached the canonical tarball for a
package, every subsequent run reuses it regardless of whether the
primary URL is healthy that day.
builder's master.yml + build-one.yml had no equivalent. Each cron run
fully re-downloads the entire dl/ tree, so every package's upstream
must be healthy in every run. Hit hard 2026-05-24 by
wireguard-tools-1.0.20210914.tar.xz: it's served from a cgit
`snapshot/` URL on git.zx2c4.com which regenerates the tarball
per-request with shifting sha256, and `sources.buildroot.net` also
served drifted bytes — see kaeru
'wireguard-zx2c4-tarball-regeneration-hash-drift'. Result: random
matrix entries fail with hash mismatches each cron, with selection
roulette-style (93 boards enable wireguard; on any given run a few
roll snake-eyes on the download).
Mirror firmware's pattern with one wrinkle: builder.sh does
`rm -rf openipc` at the top of every run to force a clean re-clone of
firmware, which would obliterate `openipc/output/dl/` if we cached the
default path. Cache `/tmp/builder-dl` instead (outside the openipc
tree) and point Buildroot at it via BR2_DL_DIR env var, honored per
Buildroot manual section "Location of source tarballs". Same restore +
save lifecycle as firmware's, same monthly key shape.
Effect:
- First cron after merge: full download as usual; warms /tmp/builder-dl.
- Subsequent crons: instant cache hit for unchanged packages; only
re-download for version bumps. Removes the wireguard-roulette class
of failure entirely once cache is populated.
build-one.yml gets the same treatment so single-board dispatches share
the cache pool.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When investigating a regression that lives in OpenIPC/firmware (or its
referenced packages like hisilicon-opensdk) but only manifests in a
builder build, you previously had to either roll firmware master back
or fork builder.sh manually. Adds a clean way:
gh workflow run build-one.yml -f platform=hi3516ev200_fpv \
-f firmware_ref=<sha-or-tag-or-branch>
The workflow exports OPENIPC_FW_REV as env, which builder.sh now
honours: when set, it does a full clone (not --depth=1) and checks
out the requested ref before invoking the firmware build. When unset,
behaviour is unchanged (shallow clone of master HEAD).
Immediate motivation: bisect the +180 KB rootfs growth in
hi3516ev200_fpv between 2026-05-08 (5016 KB) and 2026-05-24 (5196 KB,
76 KB over partition limit). Per kaeru
'hi3516ev200-ev300-fpv-rootfs-size-overflow-2026-05-24'. Suspect is
recent hisilicon-opensdk bumps that brought in source-built sensor
drivers for ev200; bisect across firmware commits varying
HISILICON_OPENSDK_VERSION will pinpoint which bump pushed us past
the 5120 KB limit.
Generally useful long after this specific incident:
- Reproducing user-reported regressions at a specific firmware tag.
- Locking builds to a known-good firmware revision when builder is
ahead of firmware.
- Future cross-repo bisects of any kind.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-Bld-C in the mirror of OpenIPC/firmware's nightly redesign.
Mirrors OpenIPC/firmware#2116. Adds an optional `commit` workflow_-
dispatch input to `build-one.yml` so:
- `gh workflow run build-one.yml -f platform=X -f commit=<sha>`
builds the given SHA from `git bisect run`.
- Output goes to `nightly-bisect-<short>` (prerelease) tag, distinct
from the dated `nightly-YYYYMMDD-<short>` namespace produced by
PR-Bld-A's master.yml. The manifest aggregator's regex only
indexes `^nightly-[0-9]{8}-[0-9a-f]{7}$`, so one-off bisect
rebuilds never enter manifest.{json,flat}.
- Without `commit`, falls back to building HEAD and tags as
`nightly-bisect-<short>-<UTC ts>` so repeated dispatches of the
same HEAD don't collide.
Also:
- Retry budget around `bash builder.sh` matching master.yml +
OpenIPC/firmware#2129's hardening.
- BUILD_ID / BUILD_SHA / BUILD_PLATFORM env at the Build firmware
step level (forward-compat with Phase 3 of the mirror plan).
- Drops dead `env: TAG_NAME: latest` at workflow level.
- Release body carries sha/short/platform/one_off=true for the
aggregator's downstream consumers to easily distinguish bisect
builds from dated nightlies.
See ~/.claude/plans/mirror-nightly-redesign-to-builder.md.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-Bld-B in the mirror of OpenIPC/firmware's nightly redesign. Adds
the two workflows that turn dated nightly-* releases (from PR-Bld-A)
into a queryable index served via GitHub Pages.
`manifest.yml` — triggers on successful `Build` workflow_run completion
(or manual dispatch). Runs `.github/scripts/enrich_manifest.py` to
enumerate dated releases, keep the 90 newest, parse the body
(sha/short/built_at from PR-Bld-A) and asset list, then emit:
- `manifest.json` — rich JSON for hosts/agents/CI.
- `manifest.flat` — whitespace-delimited columns
`build_id platform flash size url` plus `@channel` records,
parseable by pure busybox `awk` (no jq, no jsonfilter) for
on-device sysupgrade in Phase 2.
Both committed to the `gh-pages` branch (already bootstrapped).
`cleanup.yml` — Mondays 06:00 UTC (offset by +1h from firmware's
05:00 UTC cleanup). Deletes dated nightly releases beyond the 90
newest via `gh release delete --cleanup-tag`, then re-triggers
manifest.yml.
`enrich_manifest.py` differs from firmware's only in the asset parser:
builder produces TWO filename forms (per `master.yml`'s COMMON awk
trick that counts underscores in the matrix entry name):
- **Compound** `<soc>_<variant>_<vendor>-<model>-nor.tgz` →
platform key = the full string. This is the common case
(per-device builds).
- **Simple** `openipc.<soc>-<flash>-<variant>.tgz` (firmware-style,
when matrix name has one underscore) → platform key =
`<soc>_<variant>`.
The platform key gets carried into manifest.flat as a single column.
sysupgrade's existing `awk '$2==p'` lookup works unchanged; Phase 3
(model-aware BUILD_PLATFORM field in os-release) will let cameras
filter against the full per-model key.
URLs after merge:
https://openipc.github.io/builder/manifest.jsonhttps://openipc.github.io/builder/manifest.flat
(`gh-pages` branch + Pages config already set up; mirrors firmware-
side setup.)
Concurrency-grouped `gh-pages-manifest` so manifest.yml and
cleanup.yml can't race the gh-pages push.
Includes the retry hardening from firmware OpenIPC/firmware#2129
(4-attempt backoff for transient GitHub API 401/5xx, fast-fail on
permanent 404).
First-run behaviour validated locally: empty index emits explicit
placeholder. Parser unit-tested across 7 synthetic filenames covering
compound, simple, and edge cases.
See ~/.claude/plans/mirror-nightly-redesign-to-builder.md.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the OpenIPC/firmware nightly redesign (PRs #2111-#2129) to
builder. This commit is PR-Bld-A: the build.yml-side changes only.
Subsequent PRs add the manifest aggregator + retention sweep
(PR-Bld-B) and the build-one.yml commit input (PR-Bld-C).
Changes:
1. **Preflight SHA gate**. New `preflight` job reads `sha=` from the
rolling `nightly` release body. On schedule events it short-
circuits the matrix when HEAD matches the previously-published
SHA; PRs and workflow_dispatch always build. Mirrors firmware's
#2111.
2. **Three release tags per build**:
- `nightly-YYYYMMDD-<short>` (prerelease, content-addressable,
the unit of history — 90-day retention via PR-Bld-B's
cleanup.yml).
- `nightly` (rolling channel pointer; body carries sha/short/
built_at).
- `latest` (legacy alias, kept one cycle so existing sysupgrade
clients hitting the old hardcoded URL don't break).
3. **`BUILD_ID` / `BUILD_SHA` / `BUILD_PLATFORM` env** exported at
the job level from preflight outputs. firmware's rootfs_script.sh
(which builder.sh reuses via its clone of firmware) picks BUILD_ID
+ BUILD_SHA into /etc/os-release automatically. BUILD_PLATFORM is
set to the matrix entry (e.g. `gk7205v200_fpv_caddx-fly`) and is
forward-compatible with Phase 3 of the mirror plan (the firmware-
side rootfs_script.sh update that emits BUILD_PLATFORM as an
os-release field).
4. **Retry budget** around `bash builder.sh`. Matches firmware's
`30 60 120 300 600 1200` ladder, covering toolchain/CDN flakes
(#2115 + #2129 in firmware).
5. **Telegram caption** leads with `Build: <build_id>`; obsolete
`Tag: ${TAG_NAME}` line gone; workflow-level `env: TAG_NAME=
latest` removed.
No on-device firmware changes; builder cameras keep their existing
sysupgrade legacy URL until Phase 2 of the mirror (sysupgrade variant
routing) lands in OpenIPC/firmware.
See ~/.claude/plans/mirror-nightly-redesign-to-builder.md.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Build workflow's matrix-of-everything makes it slow and noisy to
test a single device profile. Mirrors the build-one workflow already
present in OpenIPC/firmware.
Takes a full platform name (e.g. hi3518ev200_lite_switcam-hs303-v2)
and runs only that build via builder.sh.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>