From 85d943ede119ab0d773af88b9560a08196fca186 Mon Sep 17 00:00:00 2001 From: Evan Severson <208220+eseverson@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:26:01 -0700 Subject: [PATCH 1/2] ci: assemble the cv6xx NOR image into the partitions its u-boot declares create_hisi laid u-boot at 0 and the combined firmware.bin verbatim at the firmware offset, on a canvas hardcoded to 16384 KiB. Two things were wrong with that, one new and one old. The new one: a lite variant is flashed to an 8 MiB part, and an image whose tail runs past the end of the chip cannot be written to one. The part size is now the sixth argument, defaulting to 16384 so every existing caller keeps its behaviour, and the cv6xx loop emits both variants at the size each is built for. The old one: the boot images this job pairs with the firmware carry their partition table in a compiled-in env, and nothing rewrites it later. Decompressed from boot-hi3516cv610-*-nor.bin (a HiSilicon boot table with a gzip'd u-boot behind it): cv6xx 256k(boot),64k(env),2048k(kernel),5120k(rootfs),7168k@0x50000(firmware),-(rootfs_data) bootnor: sf read ${kernaddr}=0x50000 ${kernsize}=0x200000; root=/dev/mtdblock3 dv500 512k(boot),256k(env),6144k(kernel),8192k(rootfs),14336k@0xC0000(firmware),-(rootfs_data) So u-boot reads the kernel as one fixed slot and mounts root at a fixed offset behind it. firmware.bin is the FIT with the squashfs packed at the next 64 KiB boundary (board post-image.sh), which only agrees with that table when the FIT happens to end exactly at the slot. On every published cv6xx and dv500 image it does not: root=/dev/mtdblockN points 704 KiB before the squashfs on cv6xx ultimate and 512 KiB past it on dv500, and the cv6xx FIT is larger than its 2048 KiB slot, so `sf read` hands bootm a truncated image. sysupgrade already gets this right (do_update_firmware splits the blob at the FIT boundary and writes each half to its partition); a fresh whole-chip flash of the release .bin did not. Do what sysupgrade does: split at the FIT boundary (the FIT's total size is the big-endian word at byte 4; the squashfs length is bytes_used in its superblock), put the FIT at the firmware offset and the rootfs at the rootfs partition, and measure each piece against the partition it goes into -- u-boot against the boot partition (the env sits behind it, so the firmware offset is the wrong bound), the FIT against the kernel slot, the rootfs against the rootfs slot, the firmware partition against the part. Each failure prints a ::error:: and fails the step rather than uploading something that boots a truncated kernel or overwrites rootfs_data. The exact-size check on the assembled canvas stays as the backstop. The layout table is a case on $soc inside the job; adding a family means adding its u-boot's env line there. With this in place the cv6xx ultimate image on master fails the FIT check (2709 KiB in 2048) and dv500 ultimate fails the rootfs check (8832 KiB in 8192) -- both are the same table disagreement, now reported instead of published. #2447 fits both cv6xx slots. A refusal must not take the other SoCs' images with it. The step runs under Actions' default `bash -e`, and the Sigmastar, Allwinner and Ingenic images are assembled before the HiSilicon loops, so a bare `return 1` there would end the step with their .bin files in target/ and the upload never reached. The HiSilicon calls collect their failures instead, the step exits 1 after the last one so the run is red, and Upload runs on `always()` so what did assemble ships. --- .github/workflows/image.yml | 128 ++++++++++++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 14 deletions(-) diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index ff3625f5c3..227675de7b 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -123,11 +123,36 @@ jobs: # (firmware.bin.) flashed at the SoC's firmware-partition # offset. So assemble one full NOR image per DDR binning: # u-boot at 0, firmware.bin at . + # Where the image has to land is decided by the u-boot it is paired + # with: the cv610 and dv500 boot images carry their partition table + # in a compiled-in env (a HiSilicon boot table with a gzip'd u-boot + # behind it), and nothing rewrites that table later. Decompressed: + # + # cv6xx 256k(boot),64k(env),2048k(kernel),5120k(rootfs),7168k@0x50000(firmware),-(rootfs_data) + # bootnor: sf read ${kernaddr}=0x50000 ${kernsize}=0x200000; root=/dev/mtdblock3 + # dv500 512k(boot),256k(env),6144k(kernel),8192k(rootfs),14336k@0xC0000(firmware),-(rootfs_data) + # + # So the kernel is read as one 2048/6144 KiB slot and the rootfs is + # mounted at a fixed offset behind it. firmware.bin is the FIT with + # the squashfs packed at the next 64 KiB boundary (board + # post-image.sh), which is only the same thing if the FIT happens to + # end exactly at the slot. Split the blob the way sysupgrade's + # do_update_firmware does and put each half where the env looks. + hisi_layout() { + case "$1" in + hi3516cv6xx) boot_kib=256 kern_kib=2048 fw_kib=7168 ;; + hi3519dv500) boot_kib=512 kern_kib=6144 fw_kib=14336 ;; + *) echo "::error::create_hisi: no NOR layout known for $1"; return 1 ;; + esac + } + create_hisi() { - local uboot="$1" soc="$2" variant="$3" tag="$4" off="$5" + local uboot="$1" soc="$2" variant="$3" tag="$4" off="$5" nor="${6:-16384}" local release="target/openipc-${tag}-nor-${variant}.bin" + local boot_kib kern_kib fw_kib fw fitsz fit_kib rootsz root_kib ub_kib got + hisi_layout "$soc" || return 1 - mkdir -p output target + rm -rf output; mkdir -p output target if ! wget -nv "$UBOOT_URL/$uboot" -O "output/$uboot"; then echo -e "Download failed: $UBOOT_URL/$uboot\n" return 0 @@ -144,14 +169,70 @@ jobs: fi tar -xf "output/$soc.tgz" -C output - # 16 MiB NOR, erased (0xff); u-boot at 0, firmware.bin at its - # partition offset (cv6xx MTDPARTS: firmware @ 0x50000 = 320 KiB). - dd if=/dev/zero bs=1K count=16384 status=none | tr '\000' '\377' > "$release" + fw="output/firmware.bin.$soc" + + # The FIT is a flattened device tree: its total size is the + # big-endian word at byte 4. The rootfs starts at the next 64 KiB. + set -- $(od -An -tu1 -j4 -N4 "$fw") + fitsz=$(( ($1 << 24) | ($2 << 16) | ($3 << 8) | $4 )) + if [ "$fitsz" -le 0 ] || [ "$fitsz" -gt "$(stat -c%s "$fw")" ]; then + echo "::error::${soc}_${variant}: firmware.bin does not start with a FIT image" + return 1 + fi + fit_kib=$(( (fitsz + 65535) / 65536 * 64 )) + # The squashfs superblock carries its own length (bytes_used, a + # little-endian u64 at byte 40); measure that rather than the + # blob's remainder, which post-image.sh pads to an erase block. + rootsz=$(od -An -tu8 -j "$(( fit_kib * 1024 + 40 ))" -N8 "$fw" | tr -d ' ') + if [ "$(od -An -c -j "$(( fit_kib * 1024 ))" -N4 "$fw" | tr -d ' ')" != "hsqs" ] || [ "${rootsz:-0}" -le 0 ]; then + echo "::error::${soc}_${variant}: no squashfs at ${fit_kib} KiB in firmware.bin" + return 1 + fi + root_kib=$(( (rootsz + 4095) / 4096 * 4 )) # mksquashfs pads the image to 4 KiB + ub_kib=$(( ( $(stat -c%s "output/$uboot") + 1023 ) / 1024 )) + + # Every bound is the partition u-boot declares, not the space left + # on the chip: past the kernel slot the FIT is truncated by sf read, + # past the rootfs slot the squashfs is written over rootfs_data. + if [ "$ub_kib" -gt "$boot_kib" ]; then + echo "::error::${soc}_${variant}: u-boot ${uboot} is ${ub_kib} KiB, the boot partition is ${boot_kib} KiB (the env sits behind it)" + return 1 + fi + if [ "$fit_kib" -gt "$kern_kib" ]; then + echo "::error::${soc}_${variant}: the FIT is ${fit_kib} KiB, the kernel partition is ${kern_kib} KiB" + return 1 + fi + if [ "$root_kib" -gt "$(( fw_kib - kern_kib ))" ]; then + echo "::error::${soc}_${variant}: the rootfs is ${root_kib} KiB, the rootfs partition is $(( fw_kib - kern_kib )) KiB" + return 1 + fi + if [ "$(( off + fw_kib ))" -gt "$nor" ]; then + echo "::error::${soc}_${variant}: firmware partition ends at $(( off + fw_kib )) KiB on a ${nor} KiB part" + return 1 + fi + + # NOR canvas, erased (0xff): u-boot at 0, the FIT at the firmware + # offset, the rootfs at the rootfs partition; the env and + # rootfs_data stay erased, so u-boot boots its defaults and /init + # formats the overlay. $nor is the part size in KiB -- a lite + # variant is flashed to an 8 MiB part. + dd if=/dev/zero bs=1K count="$nor" status=none | tr '\000' '\377' > "$release" dd if="output/$uboot" of="$release" bs=1K seek=0 conv=notrunc status=none - dd if="output/firmware.bin.$soc" of="$release" bs=1K seek="$off" conv=notrunc status=none + dd if="$fw" of="$release" bs=1K count="$fit_kib" seek="$off" conv=notrunc status=none + dd if="$fw" of="$release" bs=1K skip="$fit_kib" count="$root_kib" seek="$(( off + kern_kib ))" conv=notrunc status=none + + # Backstop for the checks above: if any is ever wrong, the canvas + # has grown past $nor and the file is no longer an image of the + # part. Refuse to upload it rather than ship an unflashable + # release. + got=$(stat -c%s "$release") + if [ "$got" -ne "$(( nor * 1024 ))" ]; then + echo "::error::${soc}_${variant}: assembled ${release} is ${got} bytes, expected $(( nor * 1024 ))" + return 1 + fi rm -rf output - echo -e "Created: $release\n" + echo -e "Created: $release (FIT ${fit_kib} KiB in ${kern_kib}, rootfs ${root_kib} KiB in $(( fw_kib - kern_kib )))\n" } for soc in $SIGMASTAR $ALLWINNER; do @@ -171,11 +252,22 @@ jobs: # cv608 is a single DDR2-64M part. firmware partition @ 0x50000. # The 20s/00s u-boots are picked per DDR (their 20g/00g siblings # carry the same DDR table, differing only in the socmodel env tag). - for variant in ultimate; do - create_hisi boot-hi3516cv610-10b-nor.bin hi3516cv6xx "$variant" hi3516cv610-ddr2-64m 320 - create_hisi boot-hi3516cv610-20s-nor.bin hi3516cv6xx "$variant" hi3516cv610-ddr3-128m 320 - create_hisi boot-hi3516cv610-00s-nor.bin hi3516cv6xx "$variant" hi3516cv610-ddr3-512m 320 - create_hisi boot-hi3516cv608-nor.bin hi3516cv6xx "$variant" hi3516cv608 320 + # lite targets 8 MiB parts, ultimate 16 MiB -- the same kernel and + # rootfs blob, on the flash the variant was built for. + # create_hisi returns 1 for an image it refuses to assemble. This + # step runs under Actions' default `bash -e`, so a bare call would + # end the step at the first refusal -- with the Sigmastar, Allwinner + # and Ingenic images already in target/ and the upload never + # reached, nothing would publish for anyone. Collect instead: every + # target gets its turn, what did assemble ships (Upload runs on + # failure too), and the step still goes red so the refusal is seen. + hisi_failed= + for variant in lite ultimate; do + case $variant in lite) nor=8192 ;; *) nor=16384 ;; esac + create_hisi boot-hi3516cv610-10b-nor.bin hi3516cv6xx "$variant" hi3516cv610-ddr2-64m 320 "$nor" || hisi_failed=1 + create_hisi boot-hi3516cv610-20s-nor.bin hi3516cv6xx "$variant" hi3516cv610-ddr3-128m 320 "$nor" || hisi_failed=1 + create_hisi boot-hi3516cv610-00s-nor.bin hi3516cv6xx "$variant" hi3516cv610-ddr3-512m 320 "$nor" || hisi_failed=1 + create_hisi boot-hi3516cv608-nor.bin hi3516cv6xx "$variant" hi3516cv608 320 "$nor" || hisi_failed=1 done # HiSilicon Hi3519DV500 (aarch64 V5). Same scheme: a single shared @@ -184,11 +276,19 @@ jobs: # (6-layer) and dmebpro (4-layer flyby), both DDR4-2666 2 GB. NOR # layout (16 MiB): u-boot @0, env @0x80000, firmware @0xC0000 = 768 KiB. for variant in ultimate; do - create_hisi boot-hi3519dv500-dmeb-nor.bin hi3519dv500 "$variant" hi3519dv500-dmeb 768 - create_hisi boot-hi3519dv500-dmebpro-nor.bin hi3519dv500 "$variant" hi3519dv500-dmebpro 768 + create_hisi boot-hi3519dv500-dmeb-nor.bin hi3519dv500 "$variant" hi3519dv500-dmeb 768 || hisi_failed=1 + create_hisi boot-hi3519dv500-dmebpro-nor.bin hi3519dv500 "$variant" hi3519dv500-dmebpro 768 || hisi_failed=1 done + if [ -n "$hisi_failed" ]; then + echo "::error::one or more HiSilicon NOR images were refused (see above); the rest were assembled and will still be uploaded" + exit 1 + fi + + # always(): a refused HiSilicon image fails the Prepare step on purpose, + # and the images that did assemble must still ship. - name: Upload + if: always() uses: softprops/action-gh-release@v2 with: tag_name: image From fa47769aa43326d04b34802721339393fee09cda Mon Sep 17 00:00:00 2001 From: AI Dev Date: Sat, 19 Sep 2026 09:41:13 +0000 Subject: [PATCH 2/2] ci: let a cancelled image run cancel always() on the upload step covers failure, which is what the refusal path needs, but it also covers cancellation. create_hisi writes each release in four passes -- erased canvas, u-boot, FIT, rootfs -- so a run cancelled between them leaves a .bin that is a bootloader on an otherwise blank part, and always() publishes it to the image release looking exactly like a finished one. That is the failure this whole change exists to prevent. !cancelled() keeps both properties that mattered: a refused image still fails the step, and the images that did assemble still ship. --- .github/workflows/image.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 227675de7b..86d4a8277f 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -285,10 +285,15 @@ jobs: exit 1 fi - # always(): a refused HiSilicon image fails the Prepare step on purpose, - # and the images that did assemble must still ship. + # A refused HiSilicon image fails the Prepare step on purpose, and the + # images that did assemble must still ship -- so this runs on failure. + # Not always(): that runs on cancellation too, and create_hisi lays down + # the canvas, u-boot, the FIT and the rootfs as four separate writes, so a + # cancelled run leaves a bootloader on an otherwise blank part. Uploading + # that publishes an unflashable image indistinguishable from a finished + # one. The ${{ }} is load-bearing: a bare `!` starts a YAML tag. - name: Upload - if: always() + if: ${{ !cancelled() }} uses: softprops/action-gh-release@v2 with: tag_name: image