diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
deleted file mode 100644
index a83e6e8aa3..0000000000
--- a/.github/CODEOWNERS
+++ /dev/null
@@ -1,9 +0,0 @@
-## @file
-# CODEOWNERS
-#
-# Copyright (c) 2022, Intel Corporation. All rights reserved.
-# SPDX-License-Identifier: BSD-2-Clause-Patent
-##
-
-* @cronyx @FlyRouter @viktorxda
-README.* @p0i5k @ystinia @ZigFisher
diff --git a/.github/scripts/check_target_modules.sh b/.github/scripts/check_target_modules.sh
deleted file mode 100755
index 6918d34332..0000000000
--- a/.github/scripts/check_target_modules.sh
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/bin/sh
-# Regression check: assert that every kernel-module package selected in
-# the build's .config produced its expected .ko under output/target/lib/modules/.
-#
-# Catches per-package merge regressions like #2032 where extra/wireguard.ko
-# silently disappeared from firmware on hi3516cv200/hi3518ev200 builds.
-#
-# CI-only — not part of the local make flow, so contributors experimenting
-# locally don't get blocked. Add new (config var, .ko) pairs to the list
-# below whenever a new kernel-module package lands.
-set -eu
-
-CONFIG="${1:-output/.config}"
-TARGET_DIR="${2:-output/target}"
-
-if [ ! -f "$CONFIG" ]; then
- echo "check_target_modules: config file not found: $CONFIG" >&2
- exit 1
-fi
-
-if [ ! -d "$TARGET_DIR/lib/modules" ]; then
- echo "check_target_modules: no /lib/modules in $TARGET_DIR — nothing to verify"
- exit 0
-fi
-
-fail=0
-
-# config-var → expected .ko filename
-# Add new pairs here when a kernel-module package is introduced.
-check_module() {
- var="$1"
- ko="$2"
- grep -q "^${var}=y" "$CONFIG" || return 0
- if find "$TARGET_DIR/lib/modules" -name "$ko" | grep -q .; then
- echo "OK: $ko present (${var}=y)"
- else
- echo "MISSING: ${var}=y but $ko not found under $TARGET_DIR/lib/modules/" >&2
- fail=1
- fi
-}
-
-check_module BR2_PACKAGE_WIREGUARD_LINUX_COMPAT wireguard.ko
-
-if [ "$fail" -ne 0 ]; then
- echo "check_target_modules: regression detected" >&2
- exit 1
-fi
-echo "check_target_modules: all expected kernel modules present"
diff --git a/.github/scripts/enrich_manifest.py b/.github/scripts/enrich_manifest.py
deleted file mode 100644
index abb463eff6..0000000000
--- a/.github/scripts/enrich_manifest.py
+++ /dev/null
@@ -1,227 +0,0 @@
-#!/usr/bin/env python3
-"""Build manifest.json and manifest.flat for the nightly-* release index.
-
-Reads release metadata via the `gh` CLI (auth via $GH_TOKEN) and writes
-two files into the directory given as the first argument:
-
- - manifest.json — full index for hosts, agents, CI tools
- - manifest.flat — whitespace-delimited index for busybox-shell consumers
- (on-device sysupgrade)
-
-md5 is intentionally omitted from the v1 schema. Each `.tgz` already
-ships an in-archive `.md5sum` sidecar that sysupgrade validates after
-download (`general/overlay/usr/sbin/sysupgrade:93-95`), so a manifest-
-level md5 would be redundant and expensive to compute (would require
-downloading every asset on each manifest rebuild).
-"""
-from __future__ import annotations
-
-import datetime as dt
-import json
-import os
-import re
-import subprocess
-import sys
-import time
-from pathlib import Path
-
-REPO = os.environ.get("GITHUB_REPOSITORY", "OpenIPC/firmware")
-RETENTION = 90
-TAG_RE = re.compile(r"^nightly-(\d{8})-([0-9a-f]{7})$")
-ASSET_RE = re.compile(r"^openipc\.([^.]+)-(nor|nand)-(lite|ultimate|neo)\.tgz$")
-
-# Defconfig lines for the SoC-alias scan: a published image's SOC_MODEL plus
-# the space-separated retired/compatible ids it also serves (SOC_ALIASES).
-SOC_MODEL_RE = re.compile(r'^BR2_OPENIPC_SOC_MODEL\s*=\s*"?([A-Za-z0-9]+)"?\s*$')
-SOC_ALIASES_RE = re.compile(r'^BR2_OPENIPC_SOC_ALIASES\s*=\s*"?([^"\n]*)"?\s*$')
-
-# Retry budget for transient GitHub API failures (HTTP 401 Bad credentials,
-# 5xx, rate-limit) observed on workflow_run-triggered runs 2026-05-23.
-GH_RETRY_DELAYS = (0, 5, 15, 40) # 4 attempts; last delay before final try
-
-
-def gh(*args: str) -> str:
- # Always pass --repo so we don't depend on a .git in cwd
- # (the workflow runs the script from a path without .git).
- # Retry on transient failures (the GitHub API/token plane has flaky days);
- # surface to stderr so failures are visible in the action log.
- cmd = ["gh", *args, "--repo", REPO]
- last_exc = None
- for delay in GH_RETRY_DELAYS:
- if delay > 0:
- time.sleep(delay)
- try:
- return subprocess.check_output(cmd, text=True, stderr=subprocess.PIPE)
- except subprocess.CalledProcessError as e:
- last_exc = e
- err = e.stderr or ""
- # Permanent failures — don't waste the retry budget.
- if "release not found" in err.lower() or "not found (HTTP 404)" in err:
- break
- sys.stderr.write(
- f"gh {' '.join(args[:3])}: attempt failed "
- f"(rc={e.returncode}): {err.strip()[:240]}\n"
- )
- sys.stderr.write(
- f"gh {' '.join(args[:3])}: giving up; "
- f"final stderr:\n{last_exc.stderr}\n"
- )
- raise last_exc
-
-
-def list_dated_releases() -> list[dict]:
- raw = gh("release", "list", "--limit", "200",
- "--json", "tagName,createdAt,isPrerelease")
- rels = json.loads(raw)
- dated = [r for r in rels if TAG_RE.match(r["tagName"])]
- dated.sort(key=lambda r: r["createdAt"], reverse=True)
- return dated[:RETENTION]
-
-
-def fetch_release(tag: str) -> dict:
- raw = gh("release", "view", tag,
- "--json", "tagName,createdAt,body,assets")
- return json.loads(raw)
-
-
-def parse_body(body: str | None) -> tuple[str, str, str]:
- sha = short = built_at = ""
- for line in (body or "").splitlines():
- if line.startswith("sha="):
- sha = line[4:].strip()
- elif line.startswith("short="):
- short = line[6:].strip()
- elif line.startswith("built_at="):
- built_at = line[9:].strip()
- return sha, short, built_at
-
-
-def parse_asset(name: str) -> tuple[str, str] | None:
- m = ASSET_RE.match(name)
- if not m:
- return None
- soc, flash, variant = m.groups()
- return f"{soc}_{variant}", flash
-
-
-def scan_aliases() -> dict[str, str]:
- """Map each retired/compatible SoC id -> the canonical SOC_MODEL it is
- published under, read from BR2_OPENIPC_SOC_ALIASES in the in-tree
- defconfigs. Lets on-device sysupgrade route a camera still reporting the
- old id (xm550, gk7205v210, hi3516cv610, ...) to the image that exists.
- Best-effort: returns {} if the defconfig tree is not beside this script.
- """
- try:
- root = Path(__file__).resolve().parents[2]
- except (IndexError, OSError):
- return {}
- aliases: dict[str, str] = {}
- for cfg in sorted(root.glob("br-ext-chip-*/configs/*_defconfig")):
- try:
- text = cfg.read_text()
- except OSError:
- continue
- model = ""
- alias_field = ""
- for line in text.splitlines():
- m = SOC_MODEL_RE.match(line)
- if m:
- model = m.group(1)
- continue
- a = SOC_ALIASES_RE.match(line)
- if a:
- alias_field = a.group(1)
- if not model or not alias_field.strip():
- continue
- for chip in alias_field.split():
- if not chip or chip == model:
- continue
- prev = aliases.get(chip)
- if prev and prev != model:
- sys.stderr.write(
- f"alias conflict: {chip} -> {prev} and {model}; keeping {prev}\n"
- )
- continue
- aliases[chip] = model
- return dict(sorted(aliases.items()))
-
-
-def main() -> None:
- out_dir = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
- out_dir.mkdir(parents=True, exist_ok=True)
-
- aliases = scan_aliases()
- now = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
- dated = list_dated_releases()
-
- if not dated:
- manifest = {"schema": 1, "generated_at": now,
- "channels": {}, "aliases": aliases, "builds": []}
- (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
- flat = [
- f"# generated_at={now}",
- "# No nightly-YYYYMMDD- releases yet — "
- "the first scheduled build will populate this index.",
- ]
- for chip, model in aliases.items():
- flat.append(f"@alias {chip} {model}")
- (out_dir / "manifest.flat").write_text("\n".join(flat) + "\n")
- print(f"manifest: 0 builds (empty index), {len(aliases)} aliases")
- return
-
- builds = []
- for rel in dated:
- info = fetch_release(rel["tagName"])
- sha, short, built_at = parse_body(info.get("body") or "")
- platforms: dict[str, dict[str, dict]] = {}
- for a in info.get("assets") or []:
- parsed = parse_asset(a["name"])
- if not parsed:
- continue
- platform, flash = parsed
- platforms.setdefault(platform, {})[flash] = {
- "url": a["url"],
- "size": a["size"],
- }
- builds.append({
- "id": info["tagName"],
- "sha": sha,
- "short": short,
- "built_at": built_at or info["createdAt"],
- "release_url": f"https://github.com/{REPO}/releases/tag/{info['tagName']}",
- "platforms": platforms,
- })
-
- newest = builds[0]["id"]
- manifest = {
- "schema": 1,
- "generated_at": now,
- "channels": {"nightly": newest, "latest": newest},
- "aliases": aliases,
- "builds": builds,
- }
- (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
-
- lines = [
- "# OpenIPC firmware build index",
- f"# generated_at={now}",
- "# columns: build_id platform flash size url",
- ]
- for b in builds:
- for platform, flashes in sorted(b["platforms"].items()):
- for flash, info in sorted(flashes.items()):
- lines.append(f"{b['id']} {platform} {flash} {info['size']} {info['url']}")
- lines.append("# channels")
- for ch, target in manifest["channels"].items():
- lines.append(f"@channel {ch} {target}")
- if aliases:
- lines.append("# aliases")
- for chip, model in aliases.items():
- lines.append(f"@alias {chip} {model}")
- (out_dir / "manifest.flat").write_text("\n".join(lines) + "\n")
-
- print(f"manifest: {len(builds)} builds, newest={newest}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/.github/scripts/test_load_hisilicon.sh b/.github/scripts/test_load_hisilicon.sh
deleted file mode 100755
index ff7d3c16e2..0000000000
--- a/.github/scripts/test_load_hisilicon.sh
+++ /dev/null
@@ -1,102 +0,0 @@
-#!/bin/sh
-# Regression test for load_hisilicon's os_mem_size derivation.
-#
-# Catches #2059: kernel cmdline mem=NM was unconditionally accepted as the
-# OS/MMZ split, which broke V4+CMA boards where bootargs pass mem=
-# (with the MMZ chunk CMA-reserved within it). Result: os_mem_size=mem_total,
-# the existing "[ os_mem >= total ]" guard tripped, script exited before any
-# insmod, every camera came up with empty lsmod.
-#
-# Two-part check:
-# Part 1 — logic test: synthetic harness reproducing the post-fix code.
-# Exercises the parsing rules directly; independent of script files.
-# Part 2 — drift test: every general/package/hisilicon-osdrv-*/.../load_hisilicon
-# must contain the validation block. If someone reverts the fix in any
-# family the drift test fails immediately.
-#
-# Lightweight: pure shell, no QEMU, runs in a few seconds.
-
-set -eu
-
-fail=0
-ok() { echo "ok $*"; }
-bad() { echo "FAIL $*"; fail=$((fail + 1)); }
-T() { local exp="$1" act="$2" desc="$3"
- if [ "$exp" = "$act" ]; then ok "$desc"; else bad "$desc -- want '$exp', got '$act'"; fi; }
-
-# ----- Part 1: parsing logic -----
-# Mirrors the post-fix block in every load_hisilicon. Any divergence here
-# (vs the scripts) is caught by Part 2.
-parse_os_mem() {
- cmdline="$1"; mem_total="$2"; osmem_env="$3"
- os_mem_size=$(printf '%s' "$cmdline" | awk 'BEGIN{RS=" "} /^mem=[0-9]+M/{gsub(/^mem=|M.*$/,""); print; exit}')
- if [ -n "$os_mem_size" ] && [ "$os_mem_size" -ge "$mem_total" ]; then
- os_mem_size=""
- fi
- if [ -z "$os_mem_size" ]; then
- os_mem_size="$osmem_env"
- fi
- : "${os_mem_size:=32}"
- printf '%s\n' "$os_mem_size"
-}
-
-echo "=== Part 1: os_mem_size derivation logic ==="
-# The bug case: V4+CMA cmdline passes mem=. Pre-fix this set
-# os_mem_size=128, mem_total=128, then the load_hisilicon guard "[ os_mem
-# >= total ]" exited the whole script. Post-fix the validation block
-# discards the cmdline value and falls through to the osmem env (32).
-T 32 "$(parse_os_mem 'mem=128M mmz_allocator=cma mmz=anonymous,0,0x42000000,96M' 128 32)" \
- "V4+CMA mem=128M, totalmem=128M → fall through to osmem env (#2059)"
-
-# Legacy split: cmdline mem= is strictly less than totalmem, signaling
-# a real OS/MMZ split. Use the cmdline value as authoritative.
-T 96 "$(parse_os_mem 'mem=96M mmz_allocator=hisi mmz=anonymous,0,0x46000000,32M' 128 32)" \
- "legacy mem=96M, totalmem=128M → use cmdline 96 (PR #2039 intent)"
-
-# No mem= at all — fall back to env.
-T 32 "$(parse_os_mem 'console=ttyAMA0,115200 root=/dev/mtdblock3' 64 32)" \
- "no cmdline mem= → fall back to osmem env"
-
-# Neither cmdline nor env — script default of 32.
-T 32 "$(parse_os_mem '' 64 '')" \
- "no cmdline mem=, no env → default 32"
-
-# Misconfigured cmdline (mem= over total). Still fall through to env to
-# avoid the >= guard later in the script killing the boot.
-T 64 "$(parse_os_mem 'mem=256M' 128 64)" \
- "mem=256M > totalmem=128 → fall through (avoid guard)"
-
-# Edge: mem= equals totalmem-1 (legitimate split with 1M for MMZ — silly
-# but valid). Should still use cmdline.
-T 127 "$(parse_os_mem 'mem=127M' 128 32)" \
- "mem=127M, totalmem=128M → use cmdline 127"
-
-# ----- Part 2: every hisilicon-osdrv-* script contains the validation -----
-echo
-echo "=== Part 2: validation block present in every load_hisilicon ==="
-needle='if \[ -n "\$os_mem_size" \] && \[ "\$os_mem_size" -ge "\$mem_total" \]; then'
-scripts=$(find general/package -name 'load_hisilicon' -path '*hisilicon-osdrv-*' | sort)
-
-if [ -z "$scripts" ]; then
- bad "no hisilicon-osdrv-*/files/script/load_hisilicon found — repo layout changed?"
-else
- count=$(printf '%s\n' "$scripts" | wc -l)
- echo "scanning $count load_hisilicon copies"
- for s in $scripts; do
- family=$(printf '%s\n' "$s" | sed 's|.*hisilicon-osdrv-||; s|/.*||')
- if grep -qE "$needle" "$s"; then
- ok "$family: validation block present"
- else
- bad "$family: validation block MISSING — fix from #2060 was reverted or not applied"
- fi
- done
-fi
-
-echo
-if [ "$fail" -eq 0 ]; then
- echo "All load_hisilicon regression checks passed."
- exit 0
-else
- echo "$fail check(s) failed. See https://github.com/OpenIPC/firmware/issues/2059"
- exit 1
-fi
diff --git a/.github/scripts/test_sysupgrade.sh b/.github/scripts/test_sysupgrade.sh
deleted file mode 100755
index c36c35cb8e..0000000000
--- a/.github/scripts/test_sysupgrade.sh
+++ /dev/null
@@ -1,753 +0,0 @@
-#!/bin/bash
-# Regression tests for sysupgrade's rootfs verification.
-#
-# Catches the half-flash class of bug: do_update_rootfs() used to loop-mount the
-# new rootfs to read its SoC stamp and version, but the kernel was flashed FIRST
-# and the rootfs verified AFTER. A rootfs that failed verification was only ever
-# discovered once the kernel had been committed, leaving a device with a new
-# kernel on an old rootfs and no way back. The same mount could also block
-# indefinitely (no output between the echo and the write, so a wedged mount was
-# indistinguishable from a dead tool), and it died on images that flashcp — which
-# writes the partition raw and never needs a mount — would have written fine.
-#
-# Two-part check:
-# Part 1 — behaviour: run the real sysupgrade against a stubbed device and
-# assert what reached the flash. Every failure case asserts that the
-# KERNEL WAS NEVER WRITTEN; that is the property that mattered.
-# Part 2 — drift: static assertions on the script itself, for invariants a
-# behaviour test cannot pin (e.g. an option a message advises must
-# actually exist in the parser).
-#
-# Lightweight: pure shell, no QEMU, no root, runs in a few seconds.
-
-set -u
-
-# Overridable so the suite can be pointed at an older sysupgrade to confirm it
-# actually reproduces the bugs it claims to guard against.
-SRC=${SRC:-general/overlay/usr/sbin/sysupgrade}
-fail=0
-ok() { echo "ok $*"; }
-bad() { echo "FAIL $*"; fail=$((fail + 1)); }
-
-[ -f "$SRC" ] || { echo "FAIL cannot find $SRC — run me from the repo root"; exit 1; }
-
-for t in xxd od timeout dash; do
- command -v "$t" >/dev/null 2>&1 || { echo "FAIL required tool '$t' is missing"; exit 1; }
-done
-
-# ---------------------------------------------------------------------------
-# Sandbox: the device paths sysupgrade hardcodes, redirected under $SB, plus
-# stubs on PATH for everything that would touch real hardware.
-# ---------------------------------------------------------------------------
-SB=$(mktemp -d)
-trap 'rm -rf "$SB"' EXIT
-mkdir -p "$SB/tmp" "$SB/proc" "$SB/etc/init.d" "$SB/bin"
-
-# Rewrite the absolute device paths sysupgrade hardcodes to point into $SB.
-# /dev/* is deliberately NOT rewritten: flashcp is stubbed and records its argv,
-# so the target only needs to be a recognisable string.
-#
-# Order matters, and paths are staged through sentinels rather than substituted
-# inline: $SB itself lives under /tmp, so a naive `s|/tmp|$SB/tmp|` would go on
-# to rewrite the very paths the earlier rules had just produced.
-#
-# 1. get_system_version() takes its root as "$1" — "" for the running system
-# (which must be sandboxed) and the mountpoint for the candidate rootfs
-# (which must NOT be). Give it a default and hide the literal behind a
-# sentinel so rule 3 cannot touch it. Getting this wrong makes every
-# version read empty, which silently turns the same-version test green.
-# 2. /tmp\b, before anything that inserts a /tmp path of its own.
-sed -e 's|grep "GITHUB_VERSION" "$1/etc/os-release"|grep "GITHUB_VERSION" "${1:-@SB@}@OSRELEASE@"|' \
- -e "s|/tmp\\b|@SB@/tmp|g" \
- -e "s|/etc/os-release|@SB@/etc/os-release|g" \
- -e "s|/proc/mtd|@SB@/proc/mtd|g" \
- -e "s|/proc/cmdline|@SB@/proc/cmdline|g" \
- -e "s|/proc/sys/vm/drop_caches|@SB@/tmp/drop_caches|g" \
- -e "s|/etc/init.d/|@SB@/etc/init.d/|g" \
- -e "s|@OSRELEASE@|/etc/os-release|g" \
- -e "s|@SB@|$SB|g" \
- "$SRC" > "$SB/sysupgrade"
-
-grep -q '@SB@\|@OSRELEASE@' "$SB/sysupgrade" \
- && { echo "FAIL sandbox rewrite left an unexpanded sentinel"; exit 1; }
-
-for s in S99rc.local S60crond S49ntpd S02klogd S01syslogd; do
- printf '#!/bin/sh\nexit 0\n' > "$SB/etc/init.d/$s"; chmod +x "$SB/etc/init.d/$s"
-done
-
-set_mtd() { cat > "$SB/proc/mtd"; }
-
-set_mtd <<'EOF'
-dev: size erasesize name
-mtd0: 00040000 00010000 "boot"
-mtd1: 00010000 00010000 "env"
-mtd2: 00200000 00010000 "kernel"
-mtd3: 00500000 00010000 "rootfs"
-mtd4: 00100000 00010000 "rootfs_data"
-EOF
-
-# The kernel command line decides whether this camera is running FROM the flash
-# sysupgrade is about to rewrite. The default is the real one off the lab
-# hi3516ev300 -- deliberately including mmz=anonymous and an mtdparts label
-# ending in "rootfs_data", the substrings a loose root-type probe trips over.
-set_cmdline() { printf '%s\n' "$1" > "$SB/proc/cmdline"; }
-CMDLINE_FLASH='mem=128M console=ttyAMA0,115200 panic=20 rootfstype=squashfs root=/dev/mtdblock3 mtdparts=hi_sfc:256k(boot),64k(env),2048k(kernel),5120k(rootfs),-(rootfs_data) mmz_allocator=cma mmz=anonymous,0,0x42000000,96M init=/init'
-CMDLINE_NFS='console=ttyAMA0,115200 root=/dev/nfs nfsroot=192.168.1.1:/srv/cam,tcp,v3 ip=dhcp rw'
-CMDLINE_MMC='console=ttyAMA0,115200 root=/dev/mmcblk0p2 rootfstype=ext4 rw'
-CMDLINE_RAM='console=ttyAMA0,115200 root=/dev/ram0 rdinit=/linuxrc'
-set_cmdline "$CMDLINE_FLASH"
-
-# --- stubs -----------------------------------------------------------------
-stub() { printf '#!/bin/bash\n%s\n' "$2" > "$SB/bin/$1"; chmod +x "$SB/bin/$1"; }
-
-stub ipcinfo 'case "$1" in -v) echo "${STUB_VENDOR:-sigmastar}";; -F) echo nor;; esac'
-stub fw_printenv 'echo "${STUB_SOC:-ssc338q}"'
-stub killall 'exit 0'
-stub ntpd 'exit 0'
-stub curl 'exit 0'
-stub umount 'exit 0'
-stub losetup 'case "$1" in -f) echo /dev/loop0;; *) exit 0;; esac'
-
-# download_firmware runs `md5sum -s -c`. -s (silent) is a busybox extension; GNU
-# coreutils spells it --status and rejects -s outright. Bridge it, so the real
-# checksum gate is exercised here rather than stubbed away.
-REAL_MD5=$(command -v md5sum)
-cat > "$SB/bin/md5sum" <`; record the
-# argv of anything that writes, in order, so a test can assert both WHAT was
-# written and WHETHER anything was.
-#
-# STUB_FLASHCP_FAIL makes the write fail AFTER it has been logged -- a partially
-# erased partition, which is the state do_update_firmware's `|| die` reacts to.
-stub busybox '
-applet=$1; shift
-case "$applet" in
- flashcp|flash_eraseall) echo "$applet $*" >> "$FLASH_LOG"
- [ "1" = "$STUB_FLASHCP_FAIL" ] && exit 1 ;;
- reboot) echo "reboot" >> "$FLASH_LOG"; exit 0 ;;
-esac
-exit 0'
-
-# The verify-mount. STUB_MOUNT picks the behaviour under test.
-# ok — mount succeeds; populate the mountpoint like a real rootfs
-# fail — mount fails the way a missing squashfs decompressor does
-# hang — mount blocks; only a bounded caller survives this
-# A bare `mount` (check_sdcard's `mount | grep /mnt/mmc`) must stay quiet.
-stub mount '
-[ $# -eq 0 ] && exit 0
-target=${!#}
-case "${STUB_MOUNT:-ok}" in
- ok)
- mkdir -p "$target/etc"
- echo "GITHUB_VERSION=${STUB_IMG_VERSION:-2026.07.11}" > "$target/etc/os-release"
- echo "openipc-${STUB_IMG_SOC:-ssc338q}" > "$target/etc/hostname"
- exit 0 ;;
- fail)
- echo "mount: mounting /dev/loop0 on $target failed: Invalid argument" >&2
- exit 255 ;;
- hang)
- # exec, so a bounded caller TERMs the sleep itself rather than a wrapper
- # that leaves it orphaned. Far longer than mount_wait, so an unbounded
- # caller is unmistakable.
- exec sleep "${STUB_HANG_SECS:-600}" ;;
-esac'
-
-cat > "$SB/etc/os-release" <<'EOF'
-BUILD_PLATFORM=ssc338q_lite
-BUILD_OPTION=lite
-GITHUB_VERSION=2026.06.01
-BUILD_ID=nightly-20260601-aaaaaaa
-EOF
-
-# --- fixtures --------------------------------------------------------------
-# A legacy uImage: 32-byte header (magic 0x27051956, timestamp at offset 8),
-# then the name field do_update_kernel probes for the SoC via `od -j 32`.
-make_uimage() {
- printf '\x27\x05\x19\x56' > "$1" # 0..3 magic
- printf '\x00\x00\x00\x00' >> "$1" # 4..7 hcrc
- printf '\x68\x8f\x00\x00' >> "$1" # 8..11 timestamp
- dd if=/dev/zero bs=1 count=20 >> "$1" 2>/dev/null # 12..31 rest of header
- printf 'Linux-5.10-%s' "$2" >> "$1" # 32.. name -> `cut -d- -f3`
- dd if=/dev/zero bs=1 count=8 >> "$1" 2>/dev/null
-}
-
-# A FIT image: DTB magic, no uImage SoC field and no timestamp. This is the
-# shape do_update_kernel skips the SoC probe for.
-make_fit() {
- printf '\xd0\x0d\xfe\xed' > "$1"
- dd if=/dev/zero bs=1 count=60 >> "$1" 2>/dev/null
-}
-
-make_rootfs() { dd if=/dev/zero bs=1k count=8 of="$1" 2>/dev/null; }
-
-# A combined image (cv6xx): the FIT and the rootfs squashfs in one blob, rootfs
-# packed after the FIT at a 64K-aligned offset. do_update_firmware splits it on
-# the FIT totalsize (header bytes 4..7, big-endian) — one 64K block here.
-make_combined() {
- printf '\xd0\x0d\xfe\xed' > "$1" # FIT magic
- printf '\x00\x01\x00\x00' >> "$1" # totalsize 0x10000
- dd if=/dev/zero bs=1 count=65528 >> "$1" 2>/dev/null # rest of the FIT
- dd if=/dev/zero bs=1k count=8 >> "$1" 2>/dev/null # rootfs remainder
-}
-
-# Pack $1.. into a .tgz at $SB/tmp/fw.tgz the way download_firmware expects
-# (artifact names + an md5sum manifest beside them).
-make_archive() {
- local stage="$SB/stage"
- rm -rf "$stage"; mkdir -p "$stage"
- local names="" f
- for f in "$@"; do cp "$f" "$stage/"; names="$names $(basename "$f")"; done
- # List the names explicitly rather than globbing: the manifest must not end
- # up checksumming itself.
- (cd "$stage" && md5sum $names > openipc.md5sum)
- (cd "$stage" && tar cf - . | gzip > "$SB/tmp/fw.tgz")
-}
-
-# --- runner ----------------------------------------------------------------
-# run -- ; sets $OUT (combined output) and $RC.
-OUT=""; RC=0
-run() {
- rm -f "$SB/tmp/sysupgrade.lock" "$SB/tmp/flash.log"
- : > "$SB/tmp/flash.log"
- OUT=$(cd "$SB" && env PATH="$SB/bin:$PATH" \
- HASERLVER=1 FLASH_LOG="$SB/tmp/flash.log" mount_wait="${MOUNT_WAIT:-3}" \
- abort_wait=0 \
- STUB_MOUNT="${STUB_MOUNT:-ok}" STUB_VENDOR="${STUB_VENDOR:-sigmastar}" \
- STUB_SOC="${STUB_SOC:-ssc338q}" \
- STUB_IMG_SOC="${STUB_IMG_SOC:-ssc338q}" \
- STUB_IMG_VERSION="${STUB_IMG_VERSION:-2026.07.11}" \
- STUB_FLASHCP_FAIL="${STUB_FLASHCP_FAIL:-0}" \
- sh "$SB/sysupgrade" "$@" 2>&1)
- RC=$?
-}
-flashed() { grep -q "flashcp .*$1" "$SB/tmp/flash.log"; }
-erased() { grep -q "flash_eraseall .*$1" "$SB/tmp/flash.log"; }
-nothing_wrote() { ! grep -q "flashcp" "$SB/tmp/flash.log"; }
-# The busybox stub logs a bare "reboot" line, so whether the run rebooted is
-# directly observable -- which is the whole question issue #2231 turns on.
-rebooted() { grep -qx "reboot" "$SB/tmp/flash.log"; }
-# Line number of a phrase in $OUT, for ordering assertions.
-at() { printf '%s\n' "$OUT" | grep -n -- "$1" | head -1 | cut -d: -f1; }
-
-reset_env() {
- unset STUB_MOUNT STUB_VENDOR STUB_SOC STUB_IMG_SOC STUB_IMG_VERSION MOUNT_WAIT
- unset STUB_FLASHCP_FAIL
- rm -f "$SB"/tmp/*.ssc338q "$SB"/tmp/firmware.bin.* "$SB"/tmp/*.tgz "$SB"/tmp/*.md5sum
- make_uimage "$SB/tmp/uImage.ssc338q" ssc338q
- make_rootfs "$SB/tmp/rootfs.squashfs.ssc338q"
- set_cmdline "$CMDLINE_FLASH"
- set_mtd <<'EOF'
-dev: size erasesize name
-mtd0: 00040000 00010000 "boot"
-mtd1: 00010000 00010000 "env"
-mtd2: 00200000 00010000 "kernel"
-mtd3: 00500000 00010000 "rootfs"
-mtd4: 00100000 00010000 "rootfs_data"
-EOF
-}
-
-K="$SB/tmp/uImage.ssc338q"
-R="$SB/tmp/rootfs.squashfs.ssc338q"
-
-echo "=== Part 1: sysupgrade rootfs verification behaviour ==="
-
-# --- the happy path --------------------------------------------------------
-reset_env
-run -z --kernel="$K" --rootfs="$R"
-if [ "$RC" -eq 0 ] && flashed /dev/mtd2 && flashed /dev/mtd3; then
- ok "mount ok, new version -> kernel and rootfs both flashed"
-else
- bad "mount ok, new version -> expected both flashed, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-# The reason this PR exists: verification must precede the first write.
-v=$(at "Verifying rootfs from"); k=$(at "Update kernel from")
-if [ -n "$v" ] && [ -n "$k" ] && [ "$v" -lt "$k" ]; then
- ok "rootfs is verified BEFORE the kernel is flashed"
-else
- bad "verify must precede the kernel flash -- verify@${v:-none} kernel@${k:-none}"
-fi
-
-# Assignments are made on their own line, never as an env-prefix to run(): a
-# prefix on a *function* call persists in the caller under POSIX sh but not
-# under default bash, and that difference is not worth depending on.
-reset_env
-STUB_IMG_VERSION=2026.06.01 # == the installed version
-run -z --kernel="$K" --rootfs="$R"
-if [ "$RC" -eq 0 ] && ! flashed /dev/mtd3; then
- ok "mount ok, same version -> rootfs write skipped"
-else
- bad "same version -> rootfs should not be written, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-reset_env
-STUB_IMG_SOC=gk7205v300 # a foreign image
-run -z --kernel="$K" --rootfs="$R"
-if [ "$RC" -ne 0 ] && nothing_wrote; then
- ok "mount ok, wrong SoC -> refused, nothing written"
-else
- bad "wrong SoC -> expected refusal with no write, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-# --- unmountable rootfs ----------------------------------------------------
-# Not a defect in the image: the RUNNING kernel lacks the decompressor the NEW
-# image uses. flashcp never needs the mount, so this must not reject the flash.
-reset_env
-STUB_MOUNT=fail
-run -z --kernel="$K" --rootfs="$R"
-if [ "$RC" -eq 0 ] && flashed /dev/mtd2 && flashed /dev/mtd3; then
- ok "mount fails, kernel in same run -> warn and flash both"
-else
- bad "mount fail + kernel -> expected both flashed, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-if printf '%s' "$OUT" | grep -q "Invalid argument"; then
- ok "mount failure surfaces the real mount error, not just a guess"
-else
- bad "mount failure should print the underlying error; got: $(printf '%s' "$OUT" | tail -3)"
-fi
-
-# A wedged mount must not outlive the flash it guards.
-reset_env
-start=$(date +%s)
-STUB_MOUNT=hang
-MOUNT_WAIT=3
-run -z --kernel="$K" --rootfs="$R"
-elapsed=$(( $(date +%s) - start ))
-if [ "$RC" -eq 0 ] && flashed /dev/mtd2 && flashed /dev/mtd3 && [ "$elapsed" -lt 15 ]; then
- ok "mount hangs -> bounded by mount_wait (${elapsed}s), flash proceeds"
-else
- bad "mount hang -> expected a run bounded near mount_wait=3s and both flashed, rc=$RC ${elapsed}s log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-# No mount, and no kernel in the run to vouch for the SoC either: the one case
-# worth refusing -- and it costs nothing, because nothing has been written.
-reset_env
-STUB_MOUNT=fail
-run -z --rootfs="$R"
-if [ "$RC" -ne 0 ] && nothing_wrote; then
- ok "mount fails, rootfs-only -> refused, nothing written"
-else
- bad "mount fail + rootfs-only -> expected refusal with no write, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-# The advice in that refusal has to be an option the parser actually accepts.
-if printf '%s' "$OUT" | grep -q -- "--force_soc" && ! printf '%s' "$OUT" | grep -q -- "--skip_soc"; then
- ok "refusal advises --force_soc (a real option), not --skip_soc"
-else
- bad "refusal must advise --force_soc; got: $(printf '%s' "$OUT" | grep -i 'pass --' | head -1)"
-fi
-
-reset_env
-STUB_MOUNT=fail
-run -z --force_soc --rootfs="$R"
-if [ "$RC" -eq 0 ] && flashed /dev/mtd3; then
- ok "mount fails, rootfs-only, --force_soc -> proceeds"
-else
- bad "--force_soc should override the refusal, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-# A kernel in the run only vouches for the SoC if its header actually carries
-# one. A FIT does not, and check_soc is skipped for ingenic/rockchip -- so these
-# must be treated like rootfs-only, not waved through.
-reset_env
-make_fit "$SB/tmp/uImage.ssc338q"
-STUB_MOUNT=fail
-run -z --kernel="$K" --rootfs="$R"
-if [ "$RC" -ne 0 ] && nothing_wrote; then
- ok "mount fails, FIT kernel, local files -> refused (FIT carries no SoC)"
-else
- bad "FIT kernel is no SoC witness -> expected refusal, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-reset_env
-STUB_MOUNT=fail
-STUB_VENDOR=ingenic
-run -z --kernel="$K" --rootfs="$R"
-if [ "$RC" -ne 0 ] && nothing_wrote; then
- ok "mount fails, ingenic, local files -> refused (check_soc is skipped there)"
-else
- bad "ingenic kernel is no SoC witness -> expected refusal, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-# ...but a downloaded artifact is pinned to $model by the name it must have to
-# be found at all, so the same evidence gap must not refuse a real upgrade.
-reset_env
-make_fit "$SB/tmp/uImage.ssc338q"
-make_archive "$SB/tmp/uImage.ssc338q" "$SB/tmp/rootfs.squashfs.ssc338q"
-STUB_MOUNT=fail
-run -z --archive="$SB/tmp/fw.tgz"
-if [ "$RC" -eq 0 ] && flashed /dev/mtd2 && flashed /dev/mtd3; then
- ok "mount fails, FIT kernel, downloaded archive -> proceeds (name-pinned)"
-else
- bad "name-pinned archive should not be refused, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-# --- the combined image (cv6xx: FIT + rootfs in one blob) -------------------
-# do_update_firmware splits the blob and writes the two partitions separately.
-# Master verified the rootfs only after the FIT had been committed, so this path
-# carried the same half-flash as the split path -- unreported until review.
-reset_env
-make_combined "$SB/tmp/firmware.bin.ssc338q"
-make_archive "$SB/tmp/firmware.bin.ssc338q"
-run -z --archive="$SB/tmp/fw.tgz"
-if [ "$RC" -eq 0 ] && flashed /dev/mtd2 && flashed /dev/mtd3; then
- ok "combined image, mount ok -> split, kernel and rootfs both flashed"
-else
- bad "combined image -> expected both flashed, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-v=$(at "Verifying rootfs from"); k=$(at "Update kernel from")
-if [ -n "$v" ] && [ -n "$k" ] && [ "$v" -lt "$k" ]; then
- ok "combined image: rootfs is verified BEFORE the FIT is flashed"
-else
- bad "combined path must verify before the FIT write -- verify@${v:-none} kernel@${k:-none}"
-fi
-
-# The combined half-flash itself: a foreign image must cost nothing.
-reset_env
-make_combined "$SB/tmp/firmware.bin.ssc338q"
-make_archive "$SB/tmp/firmware.bin.ssc338q"
-STUB_IMG_SOC=gk7205v300
-run -z --archive="$SB/tmp/fw.tgz"
-if [ "$RC" -ne 0 ] && nothing_wrote; then
- ok "combined image, wrong SoC -> refused, FIT never written"
-else
- bad "combined + wrong SoC -> expected refusal with no write, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-# --- transcript ------------------------------------------------------------
-reset_env
-run -z --kernel="$K" --rootfs="$R"
-if printf '%s' "$OUT" | grep -q "Verifying rootfs from"; then
- ok "verify announces itself (a stall has a last line to stop at)"
-else
- bad "verify should print its own header/line before mounting"
-fi
-
-# --- -x / --no_reboot on a camera flashing its own live rootfs (issue #2231) --
-#
-# A NOR camera boots an overlay whose lowerdir is the squashfs on the "rootfs"
-# MTD partition and whose upperdir is the jffs2 on "rootfs_data" -- the two
-# partitions sysupgrade erases. free_resources() drops the page cache to make
-# room for the download, so once flashcp has rewritten that partition every
-# read that misses the cache comes back as the NEW image at STALE offsets: SSH
-# auth, libc and /etc all return garbage. Honouring --no_reboot there does not
-# leave a working camera pending a convenient reboot, it leaves a dead one that
-# still answers ping and cannot be logged into to issue the reboot at all.
-#
-# So the reboot decision cannot rest on skip_reboot alone. It has to ask what
-# actually got written, and whether this camera is running from it.
-
-echo
-echo "=== Part 1b: --no_reboot vs the live rootfs (issue #2231) ==="
-
-# The bug itself. -x must not be honoured once the live rootfs is overwritten.
-reset_env
-run -z --rootfs="$R" -x
-if [ "$RC" -eq 0 ] && flashed /dev/mtd3 && rebooted; then
- ok "-x + live rootfs rewritten -> reboots anyway"
-else
- bad "-x + live rootfs -> expected flash then reboot, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-# And it must say why, rather than rebooting a camera whose operator asked it not to.
-if printf '%s' "$OUT" | grep -q -- "--no_reboot ignored"; then
- ok "-x override explains itself"
-else
- bad "-x override must explain itself; got: $(printf '%s' "$OUT" | tail -3)"
-fi
-# The warning has to come BEFORE the write, while Ctrl-C still works: the trap
-# that shields flashcp from a dying TTY also takes the operator's way out.
-n=$(at "NOTICE"); w=$(at "Update rootfs from")
-if [ -n "$n" ] && [ -n "$w" ] && [ "$n" -lt "$w" ]; then
- ok "-x notice precedes the first write (abort window is real)"
-else
- bad "-x notice must precede the write -- notice@${n:-none} write@${w:-none}"
-fi
-
-# The flag still has a job. A kernel-only run never touches the mounted
-# partition, so the camera survives it and -x means what it says.
-reset_env
-run -z --kernel="$K" -x
-if [ "$RC" -eq 0 ] && flashed /dev/mtd2 && ! flashed /dev/mtd3 && ! rebooted; then
- ok "-x + kernel only -> honoured, no reboot"
-else
- bad "-x + kernel only -> expected no reboot, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-# ...and exits 0. A deliberate --no_reboot used to report failure.
-if printf '%s' "$OUT" | grep -q "asked me not to reboot"; then
- ok "-x honoured path prints the soft notice and exits $RC"
-else
- bad "-x honoured path should print the soft notice; got: $(printf '%s' "$OUT" | tail -3)"
-fi
-
-# Same version: do_update_rootfs returns before flashcp, nothing is written, so
-# there is nothing to reboot for. Deciding on intent rather than on what was
-# written would get this wrong.
-reset_env
-STUB_IMG_VERSION=2026.06.01
-run -z --rootfs="$R" -x
-if [ "$RC" -eq 0 ] && ! flashed /dev/mtd3 && ! rebooted; then
- ok "-x + same version -> nothing written, honoured"
-else
- bad "-x + same version -> expected no write and no reboot, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-# Not every camera runs from the flash it writes. An NFS/SD/ram root is a
-# supported layout (general/overlay/init, general/package/openipc-nfs-root)
-# where the rootfs partition is just a target and -x is exactly the right flag.
-for c in "$CMDLINE_NFS:nfs" "$CMDLINE_MMC:mmcblk" "$CMDLINE_RAM:ram"; do
- reset_env
- set_cmdline "${c%:*}"
- run -z --rootfs="$R" -x
- if [ "$RC" -eq 0 ] && flashed /dev/mtd3 && ! rebooted; then
- ok "-x + ${c##*:} root -> rootfs flashed, honoured (not running from it)"
- else
- bad "-x + ${c##*:} root -> expected flash with no reboot, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
- fi
-done
-
-# The failure that would brick a camera is a FALSE "not on flash", so the probe
-# is anchored to the root= token. The stock hi3516ev300 command line carries
-# both `mmz=anonymous` and an mtdparts label ending `-(rootfs_data)`; a probe
-# matching a bare `ram`/`mmcblk` anywhere in the line is one vendor bootarg away
-# from waving through the exact case this test exists for.
-reset_env
-set_cmdline "$CMDLINE_FLASH ramdisk_size=8192 mmz=mmcblkish"
-run -z --rootfs="$R" -x
-if flashed /dev/mtd3 && rebooted; then
- ok "-x + decoy 'ram'/'mmcblk' substrings -> still recognised as flash root"
-else
- bad "root-type probe must anchor to root=; decoy substrings fooled it, log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-# The combined image (cv6xx) reaches the rootfs by both of its paths.
-reset_env
-make_combined "$SB/tmp/firmware.bin.ssc338q"
-make_archive "$SB/tmp/firmware.bin.ssc338q"
-run -z --archive="$SB/tmp/fw.tgz" -x
-if flashed /dev/mtd2 && flashed /dev/mtd3 && rebooted; then
- ok "-x + combined image, split path -> reboots anyway"
-else
- bad "-x + combined split -> expected both flashed and a reboot, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-# No separate kernel/rootfs partitions: one whole-blob write to "firmware",
-# which overlaps the running rootfs.
-reset_env
-set_mtd <<'EOF'
-dev: size erasesize name
-mtd0: 00040000 00010000 "boot"
-mtd1: 00010000 00010000 "env"
-mtd2: 00700000 00010000 "firmware"
-EOF
-make_combined "$SB/tmp/firmware.bin.ssc338q"
-make_archive "$SB/tmp/firmware.bin.ssc338q"
-run -z --archive="$SB/tmp/fw.tgz" -x
-if flashed /dev/mtd2 && rebooted; then
- ok "-x + combined image, whole-blob path -> reboots anyway"
-else
- bad "-x + combined whole-blob -> expected flash and reboot, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-# A write that FAILS is not a write that did not happen: flashcp erases before
-# it writes, so a partition left half-erased is every bit as unreadable. This
-# is why the run is marked dirty before the write, not after -- do_update_firmware
-# reaches reboot_system through `|| die` precisely here.
-reset_env
-set_mtd <<'EOF'
-dev: size erasesize name
-mtd0: 00040000 00010000 "boot"
-mtd1: 00010000 00010000 "env"
-mtd2: 00700000 00010000 "firmware"
-EOF
-make_combined "$SB/tmp/firmware.bin.ssc338q"
-make_archive "$SB/tmp/firmware.bin.ssc338q"
-STUB_FLASHCP_FAIL=1
-run -z --archive="$SB/tmp/fw.tgz" -x
-if [ "$RC" -ne 0 ] && rebooted; then
- ok "-x + write started then failed -> reboots anyway (partial erase is fatal too)"
-else
- bad "-x + failed write -> expected a reboot despite the failure, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-# --wipe_overlay erases "rootfs_data" -- the rw jffs2 that is the upperdir of
-# the running overlay, so overlayfs consults it for every lookup. Erasing it
-# live breaks the camera just as thoroughly as rewriting the lowerdir.
-reset_env
-run -z --wipe_overlay -x
-if erased /dev/mtd4 && rebooted; then
- ok "-x + --wipe_overlay on flash root -> reboots anyway (live upperdir erased)"
-else
- bad "-x + --wipe_overlay -> expected erase and reboot, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-# On a non-flash root init mounts a tmpfs overlay instead, so there is no live
-# upperdir on the partition being erased.
-reset_env
-set_cmdline "$CMDLINE_NFS"
-run -z --wipe_overlay -x
-if [ "$RC" -eq 0 ] && erased /dev/mtd4 && ! rebooted; then
- ok "-x + --wipe_overlay on nfs root -> honoured"
-else
- bad "-x + --wipe_overlay on nfs root -> expected no reboot, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-# Nothing was written, so a failure before the first flash still honours -x.
-reset_env
-STUB_IMG_SOC=gk7205v300
-run -z --rootfs="$R" -x
-if [ "$RC" -ne 0 ] && nothing_wrote && ! rebooted; then
- ok "-x + refusal before any write -> honoured, nothing written"
-else
- bad "-x + pre-write refusal -> expected no write and no reboot, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-
-# The default path must be untouched by all of the above.
-reset_env
-run -z --kernel="$K" --rootfs="$R"
-if [ "$RC" -eq 0 ] && flashed /dev/mtd2 && flashed /dev/mtd3 && rebooted; then
- ok "no -x -> unconditional reboot, unchanged"
-else
- bad "no -x -> expected both flashed and a reboot, rc=$RC log='$(cat "$SB/tmp/flash.log")'"
-fi
-# ...and it must not print the -x notice at people who never passed -x.
-if ! printf '%s' "$OUT" | grep -q "NOTICE\|--no_reboot ignored"; then
- ok "no -x -> no --no_reboot chatter"
-else
- bad "a run without -x should not mention --no_reboot"
-fi
-
-# ---------------------------------------------------------------------------
-echo
-echo "=== Part 2: invariants in $SRC ==="
-
-# An option named in a user-facing message must exist in the parser.
-for opt in $(grep -oE '\-\-[a-z_]+' "$SRC" | sort -u); do
- case "$opt" in
- --force_*|--wipe_overlay|--no_reboot|--no_update|--help|--web|--url|--archive|--kernel|--rootfs|--channel|--build|--list*|--connect*) continue ;;
- esac
- bad "message references '$opt', which the option parser does not accept"
-done
-grep -q -- '--skip_soc' "$SRC" \
- && bad "'--skip_soc' is not an option (the parser takes --force_soc)" \
- || ok "no reference to the non-existent --skip_soc"
-
-# The ordering this PR is about: in the split path verify must be called before
-# the kernel write, not from inside do_update_rootfs.
-vline=$(grep -n '"\$update_rootfs" \] && verify_rootfs' "$SRC" | head -1 | cut -d: -f1)
-kline=$(grep -n '"\$update_kernel" \] && do_update_kernel' "$SRC" | head -1 | cut -d: -f1)
-if [ -n "$vline" ] && [ -n "$kline" ] && [ "$vline" -lt "$kline" ]; then
- ok "split path calls verify_rootfs before do_update_kernel"
-else
- bad "split path must verify before flashing the kernel -- verify@${vline:-none} kernel@${kline:-none}"
-fi
-
-# The verification must not creep back into the write path.
-if sed -n '/^do_update_rootfs()/,/^}/p' "$SRC" | grep -qE '\bmount|losetup'; then
- bad "do_update_rootfs mounts again -- verification belongs before the first write"
-else
- ok "do_update_rootfs does not mount (it only writes)"
-fi
-
-# The bounded mount needs its fallback: CONFIG_TIMEOUT is set in busybox.config
-# but not in busybox-initramfs.config.
-if sed -n '/^mount_rootfs()/,/^}/p' "$SRC" | grep -q 'command -v timeout'; then
- ok "mount_rootfs falls back when the timeout applet is absent"
-else
- bad "mount_rootfs must tolerate a busybox built without CONFIG_TIMEOUT"
-fi
-
-if grep -q '^CONFIG_TIMEOUT=y' general/package/busybox/busybox.config; then
- ok "busybox.config still provides the timeout applet"
-else
- bad "CONFIG_TIMEOUT was dropped from busybox.config -- the bounded mount degrades"
-fi
-
-# --- issue #2231 invariants ------------------------------------------------
-
-# skip_reboot must never be the sole gate again. The behaviour tests above only
-# see the cases they were written for; this pins the shape.
-rb=$(sed -n '/^reboot_system()/,/^}/p' "$SRC")
-if printf '%s' "$rb" | grep -q 'live_flash_dirty'; then
- ok "reboot_system weighs what was written, not just skip_reboot"
-else
- bad "reboot_system must consult live_flash_dirty, not skip_reboot alone"
-fi
-
-# It also must not exit on the honoured path: die() and the main flow disagree
-# about the status, and the main flow's `exit 0` is what makes -x a success.
-# Comments are stripped -- the ones in there discuss exit codes at length.
-rbc=$(printf '%s\n' "$rb" | sed 's/#.*//')
-if printf '%s\n' "$rbc" | grep -q 'return 0' && ! printf '%s\n' "$rbc" | grep -qw 'exit'; then
- ok "reboot_system returns rather than exits (caller owns the status)"
-else
- bad "reboot_system must return on the honoured path so die() keeps its own exit 1"
-fi
-
-# Every write that lands on flash the camera is running from has to be marked,
-# and the one that does not must stay unmarked or -x loses its only real use.
-for fn in do_update_rootfs do_update_firmware do_wipe_overlay; do
- if sed -n "/^${fn}()/,/^}/p" "$SRC" | grep -q 'mark_live_flash_dirty'; then
- ok "$fn marks the live flash dirty"
- else
- bad "$fn writes flash the camera runs from and must mark it dirty"
- fi
-done
-if sed -n '/^do_update_kernel()/,/^}/p' "$SRC" | grep -q 'mark_live_flash_dirty'; then
- bad "do_update_kernel must NOT mark dirty -- the kernel partition is not mounted"
-else
- ok "do_update_kernel leaves -x alone (its partition is not mounted)"
-fi
-
-# The mark belongs before the write (a half-erased partition is just as dead)
-# and after the same-version return (which writes nothing at all).
-body=$(sed -n '/^do_update_rootfs()/,/^}/p' "$SRC")
-e=$(printf '%s\n' "$body" | grep -n 'exit_update' | head -1 | cut -d: -f1)
-m=$(printf '%s\n' "$body" | grep -n 'mark_live_flash_dirty' | head -1 | cut -d: -f1)
-f=$(printf '%s\n' "$body" | grep -n 'flashcp' | head -1 | cut -d: -f1)
-if [ -n "$e" ] && [ -n "$m" ] && [ -n "$f" ] && [ "$e" -lt "$m" ] && [ "$m" -lt "$f" ]; then
- ok "do_update_rootfs marks after the same-version return and before the write"
-else
- bad "do_update_rootfs order must be exit_update -> mark -> flashcp; got ${e:-none}/${m:-none}/${f:-none}"
-fi
-
-# The root-type probe must anchor to root=. An unanchored alternation matches a
-# bare 'ram'/'mmcblk' anywhere in the command line, and a false "not on flash"
-# is the one error here that bricks a camera.
-if grep -q 'root_on_flash' "$SRC" \
- && grep -qF 'root=(/dev/)?(nfs|mmcblk|ram)' "$SRC" \
- && ! grep -qF 'nfs\|mmcblk\|ram' "$SRC"; then
- ok "root-type probe is anchored to the root= token"
-else
- bad "root-type probe must anchor each alternative to root=, not match bare substrings"
-fi
-
-# The abort window has to stay overridable, or this suite pays for it six times.
-if grep -q 'abort_wait=${abort_wait:-' "$SRC"; then
- ok "the -x abort window is overridable (abort_wait)"
-else
- bad "abort_wait must stay overridable so the suite does not sleep through it"
-fi
-
-# The caveat belongs in --help, not only in the code.
-if sed -n '/-x, --no_reboot/,/-z, --no_update/p' "$SRC" | grep -qi 'ignored'; then
- ok "--help says -x is ignored when the live flash is rewritten"
-else
- bad "-x usage text must document that it is ignored on a live-flash rewrite"
-fi
-
-echo
-if [ "$fail" -eq 0 ]; then
- echo "All sysupgrade verification checks passed."
- exit 0
-else
- echo "$fail check(s) failed."
- exit 1
-fi
diff --git a/.github/workflows/build-one.yml b/.github/workflows/build-one.yml
deleted file mode 100644
index f017ee59de..0000000000
--- a/.github/workflows/build-one.yml
+++ /dev/null
@@ -1,137 +0,0 @@
-name: build-one
-on:
- workflow_dispatch:
- inputs:
- platform:
- description: 'Platform to build (e.g. hi3516cv100_lite)'
- required: true
- commit:
- description: 'Commit SHA to build (optional; defaults to current branch HEAD). Use this from `git bisect run` or for one-off historic rebuilds.'
- required: false
-
-jobs:
- resolve:
- name: Resolve commit
- runs-on: ubuntu-latest
- outputs:
- ref: ${{ steps.r.outputs.ref }}
- short_sha: ${{ steps.r.outputs.short_sha }}
- tag_name: ${{ steps.r.outputs.tag_name }}
- steps:
- - id: r
- run: |
- REF="${{ inputs.commit }}"
- [ -z "$REF" ] && REF="${{ github.sha }}"
- SHORT="$(printf '%s' "$REF" | cut -c1-7)"
- if [ -n "${{ inputs.commit }}" ]; then
- TAG="nightly-bisect-${SHORT}"
- else
- TAG="nightly-bisect-${SHORT}-$(date -u +%Y%m%d%H%M%S)"
- fi
- echo "ref=$REF" >> "$GITHUB_OUTPUT"
- echo "short_sha=$SHORT" >> "$GITHUB_OUTPUT"
- echo "tag_name=$TAG" >> "$GITHUB_OUTPUT"
- echo "Building ${{ inputs.platform }} at $REF -> release tag $TAG"
-
- buildroot:
- name: Firmware (${{inputs.platform}})
- needs: resolve
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout source
- uses: actions/checkout@v4
- with:
- ref: ${{ needs.resolve.outputs.ref }}
-
- - name: Prepare firmware
- run: |
- echo "nameserver 8.8.8.8" | sudo tee /etc/resolv.conf
- echo CACHE_DATE=$(date +%m) >> ${GITHUB_ENV}
-
- - name: Setup ccache
- uses: actions/cache@v4
- with:
- path: /tmp/ccache
- key: ${{inputs.platform}}-${{env.CACHE_DATE}}
-
- - name: Setup dl cache
- uses: actions/cache@v4
- with:
- path: output/dl
- key: dl-${{env.CACHE_DATE}}
- restore-keys: dl-
-
- - name: Refresh moving-ref package downloads
- run: |
- # Packages pinned to a moving ref keep a constant download filename. The dl
- # cache key is the month (date +%m) and actions/cache only saves on a key
- # miss, so that snapshot is frozen for weeks; buildroot then keeps reusing the
- # stale tarball and silently ships an old version. This is what desynced
- # majestic-webui from majestic (blanking settings labels; later a t31 lite
- # image shipped a pre-x-groups majestic vs the current webui — "No settings
- # groups in schema"). Drop both naming forms so buildroot re-fetches each run:
- # - dash: -[.tar.gz e.g. majestic-webui-dist.tar.gz
- # - dot: .][.tar.bz2 e.g. majestic.t31.lite.master.tar.bz2
- # The dot form is majestic's S3 tarball — it LOOKS S3-pinned but is a moving
- # `master` build re-uploaded every majestic CI run, with no .hash to force a
- # refetch. Only genuinely content-addressed tarballs (semver / SHA) keep cache.
- find output/dl -type f -regextype posix-extended \
- -regex '.*[-.](HEAD|master|main|dist)\.tar\.(gz|bz2|xz)' \
- -print -delete 2>/dev/null || true
-
- - name: Build firmware
- env:
- BUILD_ID: ${{ needs.resolve.outputs.tag_name }}
- BUILD_SHA: ${{ needs.resolve.outputs.ref }}
- run: |
- export GIT_HASH=$(git rev-parse --short ${GITHUB_SHA})
- export GIT_BRANCH=${GITHUB_REF_NAME}
- echo GIT_HASH=${GIT_HASH} >> ${GITHUB_ENV}
- echo GIT_BRANCH=${GIT_BRANCH} >> ${GITHUB_ENV}
-
- mkdir -p /tmp/ccache
- ln -s /tmp/ccache ${HOME}/.ccache
-
- backoffs="30 60 120 300 600 1200"
- attempt=1
- for sleep_for in $backoffs ""; do
- make BOARD=${{inputs.platform}} && break
- if [ -z "$sleep_for" ]; then
- echo "::error::build failed after ${attempt} attempts"
- exit 1
- fi
- echo "::warning::attempt ${attempt} failed, retrying after ${sleep_for}s"
- sleep "$sleep_for"
- attempt=$((attempt + 1))
- done
-
- TIME=$(date -d @${SECONDS} +%M:%S)
- echo TIME=${TIME} >> ${GITHUB_ENV}
-
- NORFW=$(find output/images -name openipc*nor*)
- if [ -e ${NORFW} ]; then
- echo NORFW=${NORFW} >> ${GITHUB_ENV}
- fi
-
- NANDFW=$(find output/images -name openipc*nand*)
- if [ -e ${NANDFW} ]; then
- echo NANDFW=${NANDFW} >> ${GITHUB_ENV}
- fi
-
- - name: Verify kernel modules
- run: sh .github/scripts/check_target_modules.sh
-
- - name: Upload firmware
- uses: softprops/action-gh-release@v2
- with:
- tag_name: ${{ needs.resolve.outputs.tag_name }}
- prerelease: true
- body: |
- sha=${{ needs.resolve.outputs.ref }}
- short=${{ needs.resolve.outputs.short_sha }}
- platform=${{ inputs.platform }}
- one_off=true
- files: |
- ${{env.NORFW}}
- ${{env.NANDFW}}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
deleted file mode 100644
index b8b1871dc0..0000000000
--- a/.github/workflows/build.yml
+++ /dev/null
@@ -1,550 +0,0 @@
-name: build
-on:
- pull_request:
- branches:
- - master
- schedule:
- - cron: '30 22 * * *'
- workflow_dispatch:
-
-jobs:
- preflight:
- name: Preflight
- runs-on: ubuntu-latest
- outputs:
- should_build: ${{ steps.gate.outputs.should_build }}
- head_sha: ${{ steps.gate.outputs.head_sha }}
- short_sha: ${{ steps.gate.outputs.short_sha }}
- build_id: ${{ steps.gate.outputs.build_id }}
- built_at: ${{ steps.gate.outputs.built_at }}
- webui_digest: ${{ steps.gate.outputs.webui_digest }}
- steps:
- - uses: actions/checkout@v4
- - id: gate
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- HEAD=$(git rev-parse HEAD)
- SHORT=$(git rev-parse --short HEAD)
- BUILD_ID="nightly-$(date -u +%Y%m%d)-${SHORT}"
- BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
- NOTES_PREV=$(gh release view nightly --json body -q .body 2>/dev/null || true)
- PREV=$(printf '%s\n' "$NOTES_PREV" | sed -n 's/^sha=//p' | head -1)
- PREV_WEBUI=$(printf '%s\n' "$NOTES_PREV" | sed -n 's/^webui=//p' | head -1)
-
- # majestic-webui ships as a rolling `dist` release asset, so a WebUI fix
- # changes what the nightly would contain without ever moving this repo's
- # HEAD — on a quiet firmware tree the schedule would skip forever and the
- # fix would never reach an image. Compare the asset's content digest as
- # well. It is content-addressed, so a re-upload of identical bytes does
- # not force a pointless rebuild. An empty value (API hiccup, or an asset
- # with no digest) falls through to building: stale is worse than spare.
- WEBUI=$(gh api repos/OpenIPC/majestic-webui/releases/tags/dist \
- --jq '.assets[]|select(.name=="majestic-webui-dist.tar.gz")|.digest // empty' \
- 2>/dev/null || true)
-
- if [ "${{ github.event_name }}" = "schedule" ] && [ "$PREV" = "$HEAD" ] \
- && [ -n "$WEBUI" ] && [ "$PREV_WEBUI" = "$WEBUI" ]; then
- echo "Skip: HEAD ($HEAD) already published as the latest nightly, majestic-webui dist unchanged ($WEBUI)."
- echo "should_build=false" >> "$GITHUB_OUTPUT"
- else
- echo "Build: $BUILD_ID (event=${{ github.event_name }}, prev=$PREV, webui=$WEBUI, prev_webui=$PREV_WEBUI)"
- echo "should_build=true" >> "$GITHUB_OUTPUT"
- fi
- echo "head_sha=$HEAD" >> "$GITHUB_OUTPUT"
- echo "short_sha=$SHORT" >> "$GITHUB_OUTPUT"
- echo "build_id=$BUILD_ID" >> "$GITHUB_OUTPUT"
- echo "built_at=$BUILT_AT" >> "$GITHUB_OUTPUT"
- echo "webui_digest=$WEBUI" >> "$GITHUB_OUTPUT"
-
- buildroot:
- name: Firmware
- needs: preflight
- if: needs.preflight.outputs.should_build == 'true'
- runs-on: ubuntu-latest
- env:
- BUILD_ID: ${{ needs.preflight.outputs.build_id }}
- BUILD_SHA: ${{ needs.preflight.outputs.head_sha }}
-
- strategy:
- fail-fast: false
- matrix:
- platform:
- # Sigmastar [I6]
- - ssc325_lite
- - ssc325de_lite
-
- # Sigmastar [I6B]
- - ssc333_lite
- - ssc335_lite
- - ssc335de_lite
- - ssc337_lite
- - ssc337de_lite
-
- # Sigmastar [I6C]
- - ssc377_lite
- - ssc377d_lite
- - ssc377de_lite
- - ssc377qe_lite
- - ssc378de_lite
- - ssc378qe_lite
-
- # Sigmastar [I6E]
- - ssc30kd_lite
- - ssc30kq_lite
- - ssc338q_lite
-
- # Ingenic [T21]
- - t10_lite
- - t20_lite
- - t21_lite
- - t30_lite
-
- # Ingenic [T23]
- - t23_lite
-
- # Ingenic [T31]
- - t31_lite
-
- # Ingenic [T40]
- - t40_lite
-
- # Hisilicon [HI3516AV100]
- - hi3516av100_lite
- - hi3516av100_neo
- - hi3516dv100_lite
-
- # Hisilicon [HI3516CV100]
- - hi3516cv100_lite
- - hi3516cv100_neo
- - hi3518cv100_lite
- - hi3518ev100_lite
-
- # Hisilicon [HI3516CV200]
- - hi3516cv200_lite
- - hi3516cv200_neo
- - hi3518ev200_lite
-
- # Hisilicon [HI3516CV300]
- - hi3516cv300_lite
- - hi3516cv300_neo
- - hi3516ev100_lite
-
- # Hisilicon [HI3516CV500]
- - hi3516av300_lite
- - hi3516av300_neo
- - hi3516cv500_lite
- - hi3516dv300_lite
-
- # Hisilicon [HI3516CV6XX]
- - hi3516cv6xx_ultimate
-
- # Hisilicon [HI3519DV500]
- - hi3519dv500_ultimate
-
- # Hisilicon [HI3516EV200]
- - hi3516dv200_lite
- - hi3516ev200_lite
- - hi3516ev300_lite
- - hi3518ev300_lite
- - hi3516ev300_neo
-
- # Hisilicon [HI3519V101]
- - hi3516av200_lite
- - hi3516av200_neo
- - hi3519v101_lite
-
- # Hisilicon [HI3520DV200]
- - hi3520dv200_lite
-
- # Hisilicon [HI3536CV100]
- - hi3536cv100_lite
-
- # Hisilicon [HI3536DV100]
- - hi3536dv100_lite
-
- # Goke [GK710X]
- - gk7102_lite
- - gk7102s_lite
-
- # Goke [GK7205V200]
- - gk7202v300_lite
- - gk7205v200_lite
- # gk7205v210 is firmware-identical to gk7205v200 — built once and
- # served via gk7205v200's BR2_OPENIPC_SOC_ALIASES (manifest @alias).
- - gk7205v300_lite
- - gk7605v100_lite
-
- # Goke [GK7205V500]
- - gk7205v500_lite
-
- # Allwinner
- - v851s_lite
-
- # Fullhan
- - fh8852v100_lite
- - fh8852v200_lite
-
- # Grainmedia
- - gm8135_lite
- - gm8136_lite
-
- # Novatek
- - nt98562_lite
- - nt98566_lite
-
- # Rockchip
- - rv1103_lite
- - rv1106_lite
- - rv1109_lite
- - rv1126_lite
-
- # Xiongmai
- - xm510_lite
- - xm530_lite
- # xm550 is firmware-identical to xm530 — built once and served via
- # xm530's BR2_OPENIPC_SOC_ALIASES (manifest @alias).
-
- # Ultimate
- - ssc333_ultimate
- - ssc335_ultimate
- - ssc335de_ultimate
- - ssc337_ultimate
- - ssc337de_ultimate
- - ssc30kd_ultimate
- - ssc30kq_ultimate
- - ssc338q_ultimate
- - t20_ultimate
- - t21_ultimate
- - t31_ultimate
- - t40_ultimate
- - hi3516av100_ultimate
- - hi3516dv100_ultimate
- - hi3518ev200_ultimate
- - hi3516cv300_ultimate
- - hi3516ev200_ultimate
- - hi3516ev300_ultimate
- - hi3518ev300_ultimate
- - hi3516av200_ultimate
- - gk7202v300_ultimate
- - gk7205v200_ultimate
- - gk7205v300_ultimate
-
- steps:
- - name: Checkout source
- uses: actions/checkout@v4
-
- - name: Prepare firmware
- run: |
- echo "nameserver 8.8.8.8" | sudo tee /etc/resolv.conf
- echo CACHE_DATE=$(date +%m) >> ${GITHUB_ENV}
-
- - name: Setup ccache
- if: github.event_name != 'pull_request'
- uses: actions/cache@v4
- with:
- path: /tmp/ccache
- key: ${{matrix.platform}}-${{env.CACHE_DATE}}
-
- - name: Restore ccache
- if: github.event_name == 'pull_request'
- uses: actions/cache/restore@v4
- with:
- path: /tmp/ccache
- key: ${{matrix.platform}}-${{env.CACHE_DATE}}
-
- - name: Setup dl cache
- if: github.event_name != 'pull_request'
- uses: actions/cache@v4
- with:
- path: output/dl
- key: dl-${{env.CACHE_DATE}}
- restore-keys: dl-
-
- - name: Restore dl cache
- if: github.event_name == 'pull_request'
- uses: actions/cache/restore@v4
- with:
- path: output/dl
- key: dl-${{env.CACHE_DATE}}
- restore-keys: dl-
-
- - name: Refresh moving-ref package downloads
- run: |
- # Packages pinned to a moving ref keep a constant download filename. The dl
- # cache key is the month (date +%m) and actions/cache only saves on a key
- # miss, so that snapshot is frozen for weeks; buildroot then keeps reusing the
- # stale tarball and the nightly silently ships an old version. This is what
- # desynced majestic-webui from majestic (blanking settings labels; later a t31
- # lite image shipped a pre-x-groups majestic vs the current webui — "No
- # settings groups in schema"). Drop both naming forms so buildroot re-fetches:
- # - dash: -][.tar.gz e.g. majestic-webui-dist.tar.gz
- # - dot: .][.tar.bz2 e.g. majestic.t31.lite.master.tar.bz2
- # The dot form is majestic's S3 tarball — it LOOKS S3-pinned but is a moving
- # `master` build re-uploaded every majestic CI run, with no .hash to force a
- # refetch. Only genuinely content-addressed tarballs (semver / SHA) keep cache.
- find output/dl -type f -regextype posix-extended \
- -regex '.*[-.](HEAD|master|main|dist)\.tar\.(gz|bz2|xz)' \
- -print -delete 2>/dev/null || true
-
- - name: Build firmware
- run: |
- export GIT_HASH=$(git rev-parse --short ${GITHUB_SHA})
- export GIT_BRANCH=${GITHUB_REF_NAME}
- echo GIT_HASH=${GIT_HASH} >> ${GITHUB_ENV}
- echo GIT_BRANCH=${GIT_BRANCH} >> ${GITHUB_ENV}
-
- mkdir -p /tmp/ccache
- ln -s /tmp/ccache ${HOME}/.ccache
-
- # Backoffs give 7 attempts total. The longer tail (600, 1200) covers
- # the GitHub releases CDN / toolchain mirror flakes that took down
- # hi3516av100_ultimate on 2026-05-20 (run 26196546684) — 502s
- # storming for >10 min outlasted the previous 30/60/120/300 budget.
- backoffs="30 60 120 300 600 1200"
- attempt=1
- for sleep_for in $backoffs ""; do
- make BOARD=${{matrix.platform}} && break
- if [ -z "$sleep_for" ]; then
- echo "::error::build failed after ${attempt} attempts"
- exit 1
- fi
- echo "::warning::attempt ${attempt} failed, retrying after ${sleep_for}s"
- sleep "$sleep_for"
- attempt=$((attempt + 1))
- done
-
- TIME=$(date -d @${SECONDS} +%M:%S)
- echo TIME=${TIME} >> ${GITHUB_ENV}
-
- NORFW=$(find output/images -name openipc*nor*)
- if [ -e ${NORFW} ]; then
- echo NORFW=${NORFW} >> ${GITHUB_ENV}
- fi
-
- NANDFW=$(find output/images -name openipc*nand*)
- if [ -e ${NANDFW} ]; then
- echo NANDFW=${NANDFW} >> ${GITHUB_ENV}
- fi
-
- - name: Verify kernel modules
- run: sh .github/scripts/check_target_modules.sh
-
- - name: Build size report
- run: |
- make BOARD=${{matrix.platform}} size-report || \
- echo "::warning::size-report failed for ${{matrix.platform}}"
- SIZES=$(find output/images -name 'sizes.*.json' | head -1)
- if [ -e "$SIZES" ]; then
- echo "SIZES=$SIZES" >> ${GITHUB_ENV}
- fi
-
- - name: Build kconfig graph
- run: |
- python3 -m pip install --user --break-system-packages kconfiglib || \
- { echo "::warning::kconfiglib install failed for ${{matrix.platform}}"; exit 0; }
- make BOARD=${{matrix.platform}} kconfig-graph || \
- { echo "::warning::kconfig-graph failed for ${{matrix.platform}}"; exit 0; }
- KCONFIG=$(find output/images -name 'kconfig-graph.*.json' | head -1)
- KHELP=$(find output/images -name 'kconfig-help.*.json' | head -1)
- if [ -e "$KCONFIG" ]; then echo "KCONFIG=$KCONFIG" >> ${GITHUB_ENV}; fi
- if [ -e "$KHELP" ]; then echo "KHELP=$KHELP" >> ${GITHUB_ENV}; fi
-
- # Hand the firmware + sidecars to the single `publish` job instead of
- # uploading to releases from here. ~90 matrix jobs all deleting and
- # re-uploading assets on the SAME shared `nightly`/`latest` releases at
- # once made the GitHub releases API return HTTP 500 ("Server Error") —
- # e.g. run 27108181857, where every build succeeded but 14 jobs went red
- # in their upload step. Consolidating every release write into one job
- # removes the concurrent-writer contention.
- - name: Upload build artifacts
- if: github.event_name != 'pull_request'
- uses: actions/upload-artifact@v4
- with:
- name: fw-${{ matrix.platform }}
- if-no-files-found: ignore
- retention-days: 1
- path: |
- output/images/openipc*.tgz
- output/images/sizes.*.json
- output/images/kconfig-graph.*.json
- output/images/kconfig-help.*.json
-
- - name: Send binary
- if: github.event_name != 'pull_request' && env.NORFW
- run: |
- TG_MSG="Build: ${BUILD_ID}\nCommit: ${GIT_HASH}\nBranch: ${GIT_BRANCH}\nTime: ${TIME}\n\n"
- TG_ICON="\xE2\x9C\x85 GitHub Actions"
- TG_HEADER=$(echo -e ${TG_MSG}${TG_ICON})
- TG_TOKEN=${{secrets.TELEGRAM_TOKEN_BOT_OPENIPC}}
- TG_CHANNEL=${{secrets.TELEGRAM_CHANNEL_OPENIPC_DEV}}
- HTTP=$(curl -s -o /dev/null -w %{http_code} https://api.telegram.org/bot${TG_TOKEN}/sendDocument -F chat_id=${TG_CHANNEL} -F caption="${TG_HEADER}" -F document=@${NORFW})
- echo Telegram response: ${HTTP}
-
- # All release writes happen here, once, so only a SINGLE job ever touches the
- # shared `nightly`/`latest` releases (and the per-run dated release). The old
- # design had every matrix job upload to these releases, so ~90 jobs raced to
- # delete/re-upload assets on the same release and the GitHub releases API
- # returned HTTP 500 ("Server Error") under that concurrency — run
- # 27108181857 built fine on every board yet went red on 14 upload steps.
- #
- # Best-effort by design: publish whatever boards produced, even if some
- # failed (matches enrich_manifest.py, which indexes whatever assets exist).
- # Never runs on PRs (no artifacts) or on a scheduled no-op (buildroot skipped).
- publish:
- name: Publish releases
- needs: [preflight, buildroot]
- if: >-
- github.event_name != 'pull_request' &&
- needs.preflight.outputs.should_build == 'true' &&
- contains(fromJSON('["success", "failure"]'), needs.buildroot.result)
- runs-on: ubuntu-latest
- permissions:
- contents: write
- steps:
- - name: Download build artifacts
- uses: actions/download-artifact@v4
- continue-on-error: true
- with:
- pattern: fw-*
- path: dist
- merge-multiple: true
-
- - name: Collect assets
- id: collect
- run: |
- mkdir -p dist
- count=$(find dist -type f | wc -l)
- echo "Collected ${count} asset(s):"
- ls -la dist || true
- echo "count=${count}" >> "$GITHUB_OUTPUT"
-
- # Drive the release writes ourselves instead of softprops/action-gh-release.
- # That action uploads assets CONCURRENTLY with no throttle/retry knob, so
- # firing ~390 asset writes at three releases tripped GitHub's per-actor
- # secondary rate limit — run 27314054079's "dated" step died after
- # uploading 350/393 assets with "You have exceeded a secondary rate limit".
- # Here every asset is uploaded one at a time, paced under the per-minute
- # mutation ceiling, with exponential backoff so a transient 403/secondary
- # limit is retried rather than failing the nightly. This is the third
- # iteration of the publish design: per-job uploads raced to HTTP 500 →
- # single-job concurrent uploads tripped the secondary limit → single-job
- # paced uploads (here). The single-writer property that fixed the 500s is
- # preserved; only the within-job upload rate changed.
- - name: Publish releases (paced, retry-aware)
- if: steps.collect.outputs.count != '0'
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_REPO: ${{ github.repository }}
- BUILD_ID: ${{ needs.preflight.outputs.build_id }}
- HEAD_SHA: ${{ needs.preflight.outputs.head_sha }}
- SHORT_SHA: ${{ needs.preflight.outputs.short_sha }}
- BUILT_AT: ${{ needs.preflight.outputs.built_at }}
- WEBUI_DIGEST: ${{ needs.preflight.outputs.webui_digest }}
- run: |
- set -euo pipefail
-
- # Retry any gh invocation with exponential backoff. Covers the 403 /
- # 429 secondary-rate-limit response per GitHub's own guidance (pause,
- # back off, retry) — the asset uploads are the calls that hit it.
- gh_retry() {
- local n=0 max=6 delay=15
- until "$@"; do
- n=$((n + 1))
- if [ "$n" -ge "$max" ]; then
- echo "::error::gh failed after ${max} attempts: $*"
- return 1
- fi
- echo "::warning::gh attempt ${n} failed, sleeping ${delay}s before retry"
- sleep "$delay"
- delay=$((delay * 2))
- done
- }
-
- # Create the release at the built commit if it does not exist yet.
- ensure_release() { # [extra gh release create args...]
- local tag="$1"; shift
- if ! gh release view "$tag" >/dev/null 2>&1; then
- gh_retry gh release create "$tag" --target "$HEAD_SHA" --notes "$NOTES" "$@"
- fi
- }
-
- # Upload one asset at a time, pacing ~1/s to stay under the per-minute
- # content-mutation ceiling that triggers the secondary rate limit.
- upload_paced() { # ...
- local tag="$1"; shift
- local total=$# i=0
- for f in "$@"; do
- i=$((i + 1))
- echo "[${tag}] (${i}/${total}) ${f##*/}"
- gh_retry gh release upload "$tag" "$f" --clobber
- sleep 1
- done
- }
-
- # `webui=` is what the next preflight diffs against to notice a WebUI-only
- # change; drop the line rather than record an empty value, so a failed
- # lookup reads as "unknown" and builds instead of matching a future empty.
- NOTES=$(printf 'sha=%s\nshort=%s\nbuilt_at=%s\n' "$HEAD_SHA" "$SHORT_SHA" "$BUILT_AT")
- if [ -n "$WEBUI_DIGEST" ]; then
- NOTES=$(printf '%s\nwebui=%s\n' "$NOTES" "$WEBUI_DIGEST")
- fi
-
- # Full asset set (firmware images + sizes/kconfig sidecars) → dated.
- mapfile -t DATED < <(find dist -maxdepth 1 -type f | sort)
- # nightly/latest are firmware-delivery aliases for flashers, so ship
- # only the firmware images there — re-uploading the small JSON sidecars
- # to two more releases is pure rate-limit cost for no consumer.
- mapfile -t IMAGES < <(find dist -maxdepth 1 -type f -name 'openipc*.tgz' | sort)
-
- # --- dated: immutable per-build history ---
- ensure_release "$BUILD_ID" --prerelease --title "$BUILD_ID"
- upload_paced "$BUILD_ID" "${DATED[@]}"
- # Completion marker, uploaded LAST: its presence is the single downstream
- # signal that the dated release finished publishing. firmware-explorer's
- # prebuild gates ingestion on it so a rate-limit-truncated release is
- # never indexed as a complete build.
- printf '{"build_id":"%s","expected_assets":%s,"head_sha":"%s","built_at":"%s"}\n' \
- "$BUILD_ID" "${#DATED[@]}" "$HEAD_SHA" "$BUILT_AT" > dist/_manifest.json
- gh_retry gh release upload "$BUILD_ID" dist/_manifest.json --clobber
-
- # --- rolling nightly: move tag + body to this build, refresh images ---
- ensure_release nightly
- gh_retry gh release edit nightly --notes "$NOTES"
- gh_retry gh api -X PATCH "repos/${GH_REPO}/git/refs/tags/nightly" -f sha="$HEAD_SHA" -F force=true
- upload_paced nightly "${IMAGES[@]}"
-
- # --- latest: legacy alias ---
- ensure_release latest
- gh_retry gh api -X PATCH "repos/${GH_REPO}/git/refs/tags/latest" -f sha="$HEAD_SHA" -F force=true
- upload_paced latest "${IMAGES[@]}"
-
- # Single umbrella status check that reflects the whole build matrix, so the
- # branch-protection required-checks list does not need one hardcoded
- # "Firmware ()" context per board (adding/removing a board no longer
- # requires a settings change). Make "CI Gate" the required check on master
- # instead of the per-board contexts.
- ci-gate:
- name: CI Gate
- needs: [preflight, buildroot, publish]
- if: always()
- runs-on: ubuntu-latest
- steps:
- - name: Require preflight + firmware matrix to succeed
- run: |
- echo "preflight=${{ needs.preflight.result }} buildroot=${{ needs.buildroot.result }} publish=${{ needs.publish.result }}"
- if [ "${{ needs.preflight.result }}" != "success" ]; then
- echo "::error::preflight did not succeed"; exit 1
- fi
- # On pull_request, preflight always sets should_build=true so the
- # matrix runs; 'skipped' only happens on a scheduled no-op build.
- case "${{ needs.buildroot.result }}" in
- success|skipped) echo "firmware matrix OK (${{ needs.buildroot.result }})" ;;
- *) echo "::error::firmware matrix result=${{ needs.buildroot.result }}"; exit 1 ;;
- esac
- # publish is skipped on PRs and on scheduled no-ops; only a real
- # failure of the single release-writer should fail the gate.
- case "${{ needs.publish.result }}" in
- success|skipped) echo "publish OK (${{ needs.publish.result }})" ;;
- *) echo "::error::publish result=${{ needs.publish.result }}"; exit 1 ;;
- esac
diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml
deleted file mode 100644
index f76c51d56a..0000000000
--- a/.github/workflows/cleanup.yml
+++ /dev/null
@@ -1,49 +0,0 @@
-name: cleanup
-on:
- schedule:
- - cron: '0 5 * * 1' # Mondays 05:00 UTC
- workflow_dispatch:
-
-permissions:
- contents: write
- actions: write
-
-concurrency:
- group: gh-pages-manifest
- cancel-in-progress: false
-
-jobs:
- prune:
- name: Prune old nightly releases
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - name: Delete releases beyond the 90 newest
- id: prune
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- set -euo pipefail
- to_delete=$(gh release list --limit 200 --json tagName,createdAt \
- | jq -r '[ .[] | select(.tagName | test("^nightly-[0-9]{8}-[0-9a-f]{7}$")) ]
- | sort_by(.createdAt) | reverse | .[90:] | .[].tagName')
-
- if [ -z "$to_delete" ]; then
- echo "Nothing to delete; <=90 dated nightlies present."
- echo "pruned=false" >> "$GITHUB_OUTPUT"
- exit 0
- fi
-
- echo "$to_delete" | while read -r tag; do
- [ -z "$tag" ] && continue
- echo "Deleting $tag"
- gh release delete "$tag" --cleanup-tag --yes
- done
- echo "pruned=true" >> "$GITHUB_OUTPUT"
-
- - name: Refresh manifest
- if: steps.prune.outputs.pruned == 'true'
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: gh workflow run manifest.yml
diff --git a/.github/workflows/gcc-compat.yml b/.github/workflows/gcc-compat.yml
deleted file mode 100644
index 2f160388bf..0000000000
--- a/.github/workflows/gcc-compat.yml
+++ /dev/null
@@ -1,46 +0,0 @@
-name: gcc-compat
-on:
- pull_request:
- branches:
- - master
- paths:
- - 'Makefile'
- - 'general/package/**/*.mk'
- - 'general/package/all-patches/**'
- - '.github/workflows/gcc-compat.yml'
- schedule:
- - cron: '0 6 * * 0'
- workflow_dispatch:
-
-jobs:
- gcc-compat:
- name: GCC ${{matrix.gcc}}
- runs-on: ubuntu-latest
- container:
- image: gcc:${{matrix.gcc}}
-
- strategy:
- fail-fast: false
- matrix:
- gcc:
- - 12
- - 13
- - 14
- - 15
- - 16
-
- steps:
- - name: Install build dependencies
- run: |
- apt-get update
- apt-get install -y automake autotools-dev bc build-essential cpio \
- curl file git libncurses-dev libtool lzop make rsync unzip wget \
- libssl-dev
-
- - name: Checkout source
- uses: actions/checkout@v4
-
- - name: Build firmware
- run: make BOARD=gk7205v200_lite
- env:
- FORCE_UNSAFE_CONFIGURE: 1
diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml
deleted file mode 100644
index 2a4b72270a..0000000000
--- a/.github/workflows/image.yml
+++ /dev/null
@@ -1,189 +0,0 @@
-name: image
-on:
- workflow_run:
- workflows: [build]
- types: [completed]
- workflow_dispatch:
- inputs:
- build_id:
- description: 'Build ID to assemble (e.g. nightly-20260520-887328c). Default: channels.nightly from manifest.'
- required: false
-
-permissions:
- contents: write
-
-jobs:
- toolchain:
- name: Image
- runs-on: ubuntu-latest
- # Run after nightly (schedule) or operator dispatch only — PR builds do
- # not produce new release assets, so reassembling images on every PR
- # build is wasted compute. Mirrors the gate in manifest.yml.
- #
- # Publish on success AND on partial-failure: see manifest.yml rationale.
- # A flaky board doesn't deny image assembly for the platforms that did
- # upload a firmware tgz — firmware_url returns empty for missing ones and
- # create() skips them.
- if: >-
- github.event_name == 'workflow_dispatch' ||
- ((github.event.workflow_run.event == 'schedule' ||
- github.event.workflow_run.event == 'workflow_dispatch') &&
- contains(fromJSON('["success", "failure"]'), github.event.workflow_run.conclusion))
- env:
- SIGMASTAR: ssc30kd ssc30kq ssc325 ssc333 ssc335 ssc335de ssc337 ssc337de ssc338q ssc377 ssc377d ssc377de ssc377qe ssc378de ssc378qe
- INGENIC: t10 t10l t20 t20l t20x t21n t23n t30a t30a1 t30l t30n t30x t31a t31al t31l t31lc t31n t31x
- ALLWINNER: v851s
- MANIFEST_URL: https://openipc.github.io/firmware/manifest.json
- UBOOT_URL: https://github.com/openipc/firmware/releases/download/latest
-
- steps:
- - name: Resolve build_id from manifest
- id: resolve
- env:
- INPUT_BUILD_ID: ${{ inputs.build_id }}
- run: |
- set -eu
- curl -fsSL "$MANIFEST_URL" -o /tmp/manifest.json
- if [ -n "$INPUT_BUILD_ID" ]; then
- BUILD_ID="$INPUT_BUILD_ID"
- else
- BUILD_ID=$(jq -r '.channels.nightly // ""' /tmp/manifest.json)
- fi
- if [ -z "$BUILD_ID" ] || [ "$BUILD_ID" = "null" ]; then
- echo "::error::No build_id resolved (manifest channels.nightly empty? input not given?)"
- exit 1
- fi
- # Sanity check: does the manifest actually know this build?
- if ! jq -e --arg b "$BUILD_ID" '.builds[] | select(.id==$b)' /tmp/manifest.json >/dev/null; then
- echo "::error::build_id $BUILD_ID is not present in manifest at $MANIFEST_URL"
- exit 1
- fi
- echo "build_id=$BUILD_ID" >> "$GITHUB_OUTPUT"
- echo "Assembling images for $BUILD_ID"
-
- - name: Prepare
- env:
- BUILD_ID: ${{ steps.resolve.outputs.build_id }}
- run: |
- MANIFEST=/tmp/manifest.json
-
- # firmware_url
- # -> echoes the .nor URL for ${firmware_soc}_${variant} in
- # $BUILD_ID, or nothing if the manifest has no such entry.
- firmware_url() {
- jq -r --arg b "$BUILD_ID" --arg p "${1}_${2}" \
- '.builds[] | select(.id==$b) | .platforms[$p].nor.url // empty' \
- "$MANIFEST"
- }
-
- # create
- # The two SoC arguments differ for Ingenic: u-boot is per
- # board (t31al, t31lc, ...), firmware is per family (t31).
- create() {
- uboot=u-boot-$1-nor.bin
- release=target/openipc-$1-nor-$3.bin
-
- mkdir -p output target
- if ! wget -nv "$UBOOT_URL/$uboot" -O "output/$1.bin"; then
- echo -e "Download failed: $UBOOT_URL/$uboot\n"
- return 0
- fi
-
- url=$(firmware_url "$2" "$3")
- if [ -z "$url" ]; then
- echo -e "Skip: no firmware in $BUILD_ID for ${2}_${3}\n"
- return 0
- fi
- if ! wget -nv "$url" -O "output/$2.tgz"; then
- echo -e "Download failed: $url\n"
- return 0
- fi
-
- tar -xf output/$2.tgz -C output
- dd if=/dev/zero bs=1K count=5000 status=none | tr '\000' '\377' > $release
- dd if=output/$1.bin of=$release bs=1K seek=0 conv=notrunc status=none
- dd if=output/uImage.$2 of=$release bs=1K seek=320 conv=notrunc status=none
- dd if=output/rootfs.squashfs.$2 of=$release bs=1K seek=2368 conv=notrunc status=none
- rm -rf output
-
- echo -e "Created: $release\n"
- }
-
- # create_hisi
- # HiSilicon V4/V5: u-boot is published per DDR binning (the
- # boot-ROM DDR init table is baked into each boot image), and the
- # firmware .tgz holds a single pre-packed kernel+rootfs blob
- # (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 .
- create_hisi() {
- local uboot="$1" soc="$2" variant="$3" tag="$4" off="$5"
- local release="target/openipc-${tag}-nor-${variant}.bin"
-
- mkdir -p output target
- if ! wget -nv "$UBOOT_URL/$uboot" -O "output/$uboot"; then
- echo -e "Download failed: $UBOOT_URL/$uboot\n"
- return 0
- fi
-
- url=$(firmware_url "$soc" "$variant")
- if [ -z "$url" ]; then
- echo -e "Skip: no firmware in $BUILD_ID for ${soc}_${variant}\n"
- return 0
- fi
- if ! wget -nv "$url" -O "output/$soc.tgz"; then
- echo -e "Download failed: $url\n"
- return 0
- 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"
- 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
- rm -rf output
-
- echo -e "Created: $release\n"
- }
-
- for soc in $SIGMASTAR $ALLWINNER; do
- create $soc $soc lite
- create $soc $soc ultimate
- done
-
- for soc in $INGENIC; do
- create $soc ${soc:0:3} lite
- create $soc ${soc:0:3} ultimate
- done
-
- # HiSilicon Hi3516CV610/CV608 (cv6xx family). The kernel+rootfs blob
- # is identical across DDR variants; only the per-binning u-boot (DDR
- # init table) differs, so emit one full image per DDR topology.
- # cv610 spans three topologies (DDR2-64M / DDR3-128M / DDR3-512M);
- # 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
- done
-
- # HiSilicon Hi3519DV500 (aarch64 V5). Same scheme: a single shared
- # kernel+rootfs blob (firmware.bin.hi3519dv500) plus a per-binning
- # u-boot whose baked DDR reg table differs by board layout — dmeb
- # (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
- done
-
- - name: Upload
- uses: softprops/action-gh-release@v2
- with:
- tag_name: image
- make_latest: false
- files: target/*.bin
diff --git a/.github/workflows/manifest.yml b/.github/workflows/manifest.yml
deleted file mode 100644
index a9656b1109..0000000000
--- a/.github/workflows/manifest.yml
+++ /dev/null
@@ -1,63 +0,0 @@
-name: manifest
-on:
- workflow_run:
- workflows: [build]
- types: [completed]
- workflow_dispatch:
-
-permissions:
- contents: write
-
-concurrency:
- group: gh-pages-manifest
- cancel-in-progress: false
-
-jobs:
- generate:
- name: Generate manifest
- # Run after nightly (schedule) or operator dispatch only — PR builds do
- # not upload to any release, so re-emitting the manifest after each PR
- # build is wasted compute (and noisily triggers gh-pages deploys).
- #
- # Publish on success AND on partial-failure: one flaky board (e.g. a
- # transient toolchain 502) should not deny manifest updates for the 90+
- # platforms that did upload artifacts. enrich_manifest.py reads whatever
- # assets actually exist on the release, so a partial release just shows
- # up with fewer platforms in its `platforms` map.
- if: >-
- github.event_name == 'workflow_dispatch' ||
- ((github.event.workflow_run.event == 'schedule' ||
- github.event.workflow_run.event == 'workflow_dispatch') &&
- contains(fromJSON('["success", "failure"]'), github.event.workflow_run.conclusion))
- runs-on: ubuntu-latest
- steps:
- - name: Checkout master (for the script)
- uses: actions/checkout@v4
- with:
- path: master
-
- - name: Checkout gh-pages (for output)
- uses: actions/checkout@v4
- with:
- ref: gh-pages
- path: pages
-
- - name: Generate manifest
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GITHUB_REPOSITORY: ${{ github.repository }}
- run: python3 master/.github/scripts/enrich_manifest.py pages
-
- - name: Publish to gh-pages
- working-directory: pages
- run: |
- git config user.email "actions@github.com"
- git config user.name "github-actions[bot]"
- git add manifest.json manifest.flat
- if git diff --cached --quiet; then
- echo "No manifest changes; nothing to commit."
- else
- newest=$(jq -r '.channels.nightly // "empty"' manifest.json)
- git commit -m "manifest: $(date -u +%FT%TZ) — ${newest}"
- git push
- fi
diff --git a/.github/workflows/shell-tests.yml b/.github/workflows/shell-tests.yml
deleted file mode 100644
index 674b9b74f6..0000000000
--- a/.github/workflows/shell-tests.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-name: shell-tests
-on:
- pull_request:
- branches:
- - master
- push:
- branches:
- - master
- workflow_dispatch:
-
-jobs:
- load-hisilicon-parsing:
- name: load_hisilicon os_mem_size derivation
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - run: bash .github/scripts/test_load_hisilicon.sh
-
- sysupgrade-verify:
- name: sysupgrade rootfs verification
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Parse-check under both shells
- run: |
- sh -n general/overlay/usr/sbin/sysupgrade
- dash -n general/overlay/usr/sbin/sysupgrade
- - run: bash .github/scripts/test_sysupgrade.sh
diff --git a/.github/workflows/toolchain.yml b/.github/workflows/toolchain.yml
deleted file mode 100644
index df2b2a8147..0000000000
--- a/.github/workflows/toolchain.yml
+++ /dev/null
@@ -1,92 +0,0 @@
-name: toolchain
-on:
- workflow_dispatch:
-
-env:
- TAG_NAME: toolchain
-
-jobs:
- toolchain:
- name: Toolchain
- runs-on: ubuntu-22.04
-
- strategy:
- fail-fast: false
- matrix:
- platform:
- # Sigmastar
- - ssc325_lite
- - ssc335_lite
- - ssc377_lite
- - ssc338q_lite
-
- # Hisilicon
- - hi3516av100_lite
- - hi3516cv100_lite
- - hi3516cv200_lite
- - hi3516cv300_lite
- - hi3516cv500_lite
- - hi3516cv6xx_ultimate
- # aarch64 musl toolchain, built from a decoupled toolchain-only
- # config (see configs/hi3519dv500_toolchain_defconfig).
- - hi3519dv500_toolchain
- - hi3516ev200_lite
- - hi3519v101_lite
- - hi3520dv200_lite
- - hi3536cv100_lite
- - hi3536dv100_lite
-
- # Goke
- - gk7102_lite
- - gk7205v200_lite
- - gk7205v500_lite
-
- # Ingenic
- - t20_lite
- - t21_lite
- - t31_lite
- - t40_lite
-
- # Allwinner
- - v851s_lite
-
- # Fullhan
- - fh8852v100_lite
- - fh8852v200_lite
-
- # Grainmedia
- - gm8136_lite
-
- # Novatek
- - nt98562_lite
-
- # Rockchip
- - rv1106_lite
- - rv1126_lite
-
- # Xiongmai
- - xm510_lite
- - xm530_lite
-
- steps:
- - name: Checkout source
- uses: actions/checkout@v4
-
- - name: Build toolchain
- run: |
- GCC=$(make BOARD=${{matrix.platform}} toolname).tgz
- URL=https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG_NAME}/${GCC}
- echo ${URL}
- if ! wget -q --spider ${URL}; then
- make BOARD=${{matrix.platform}} toolchain
- SDK=$(find output/images -name *_sdk-buildroot.tar.gz)
- mv ${SDK} ${GCC}
- echo GCC=${GCC} >> ${GITHUB_ENV}
- fi
-
- - name: Upload toolchain
- uses: softprops/action-gh-release@v2
- with:
- tag_name: ${{env.TAG_NAME}}
- make_latest: false
- files: ${{env.GCC}}
diff --git a/.github/workflows/uboot.yml b/.github/workflows/uboot.yml
deleted file mode 100644
index 01a3c7fa1e..0000000000
--- a/.github/workflows/uboot.yml
+++ /dev/null
@@ -1,73 +0,0 @@
-name: uboot
-on:
- workflow_dispatch:
-
-permissions:
- contents: write
-
-jobs:
- toolchain:
- name: Uboot
- runs-on: ubuntu-latest
- steps:
- - name: Prepare
- run: |
- sudo apt-get update
- sudo apt-get install gcc-arm-linux-gnueabi gcc-mipsel-linux-gnu u-boot-tools lzop gnutls-dev lzma-alone uuid-dev
-
- - name: Allwinner
- run: |
- git clone https://github.com/openipc/u-boot-allwinner --depth 1
- cd u-boot-allwinner
- bash build.sh
-
- - name: Ingenic
- run: |
- git clone https://github.com/openipc/u-boot-ingenic --depth 1
- cd u-boot-ingenic
- bash build.sh
-
- - name: Sigmastar
- run: |
- git clone https://github.com/openipc/u-boot-sigmastar --depth 1
- cd u-boot-sigmastar
- bash build.sh
-
- # softprops/action-gh-release@v2 cannot update the `latest` release: its
- # updateRelease PATCH re-sends the release's stored target_commitish, which
- # for `latest` is a frozen commit SHA that diverges from where the tag now
- # points (build.yml force-moves the `latest` tag every nightly). GitHub
- # rejects that reconciliation with 403 "Resource not accessible by
- # integration" even with contents:write. Drive the upload with the gh CLI
- # instead, the same way build.yml publishes to `latest`.
- - name: Upload
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_REPO: ${{ github.repository }}
- run: |
- set -euo pipefail
- shopt -s nullglob
- files=(u-boot-*/output/*-nor.bin u-boot-*/output/*-nand.bin)
- if [ "${#files[@]}" -eq 0 ]; then
- echo "::error::no u-boot binaries found to upload"; exit 1
- fi
- echo "Uploading ${#files[@]} file(s) to release 'latest':"
- printf ' %s\n' "${files[@]}"
-
- gh_retry() { # retry gh through transient 403/429/5xx with backoff
- local n=0 max=5 delay=10
- until "$@"; do
- n=$((n + 1))
- if [ "$n" -ge "$max" ]; then
- echo "::error::gh failed after ${max} attempts: $*"; return 1
- fi
- echo "::warning::gh attempt ${n} failed, retrying in ${delay}s"
- sleep "$delay"; delay=$((delay * 2))
- done
- }
-
- gh release view latest >/dev/null 2>&1 || \
- gh_retry gh release create latest --title latest --notes "U-Boot binaries (nor/nand)"
- for f in "${files[@]}"; do
- gh_retry gh release upload latest "$f" --clobber
- done
diff --git a/general/overlay/etc/init.d/S98wireguard-watchdog b/general/overlay/etc/init.d/S98wireguard-watchdog
new file mode 100644
index 0000000000..f9ebe2c095
--- /dev/null
+++ b/general/overlay/etc/init.d/S98wireguard-watchdog
@@ -0,0 +1,91 @@
+#!/bin/sh
+
+WIRE_WATCHDOG_INTERVAL="2"
+WIREGUARD_WATCHDOG_BIN="/usr/sbin/wireguard-watchdog"
+
+WIREGUARD_CRONTABS_FLASH="/etc/crontabs/root"
+WIREGUARD_CRONTABS_RAM="/run/cron/crontabs/root"
+WIREGUARD_CRON_MARK="# run wireguard-watchdog every few minutes (autogenerated)"
+
+reload_cron() {
+ if [ -x "/etc/init.d/S60crond" ]; then
+ /etc/init.d/S60crond restart >/dev/null 2>&1
+ fi
+}
+
+wireguard_seed_ram_crontab() {
+ local cron_dir
+ cron_dir=$(dirname "$WIREGUARD_CRONTABS_RAM")
+ [ -d "$cron_dir" ] || mkdir -p "$cron_dir" || return 1
+
+ if [ ! -f "$WIREGUARD_CRONTABS_RAM" ]; then
+ cp "$WIREGUARD_CRONTABS_FLASH" "$WIREGUARD_CRONTABS_RAM" 2>/dev/null || touch "$WIREGUARD_CRONTABS_RAM" || return 1
+ chmod 600 "$WIREGUARD_CRONTABS_RAM" 2>/dev/null || true
+ fi
+}
+
+wireguard_watchdog_remove_cron() {
+ local tmp_file
+ [ -f "$WIREGUARD_CRONTABS_RAM" ] || return 0
+
+ tmp_file=$(mktemp) || return 1
+ sed '/wireguard-watchdog/d;/wireguard-watchdog every few minutes/,+1d' "$WIREGUARD_CRONTABS_RAM" >"$tmp_file"
+ cp "$tmp_file" "$WIREGUARD_CRONTABS_RAM"
+ rm -f "$tmp_file"
+ reload_cron
+}
+
+wireguard_watchdog_install_cron() {
+ local interval tmp_file
+ [ -x "$WIREGUARD_WATCHDOG_BIN" ] || return 1
+
+ wireguard_seed_ram_crontab || return 1
+
+ interval="$WIRE_WATCHDOG_INTERVAL"
+ case "$interval" in '' | *[!0-9]* | 0) interval=2 ;; esac
+ [ "$interval" -gt 59 ] && interval=59
+
+ wireguard_watchdog_remove_cron || return 1
+
+ tmp_file=$(mktemp) || return 1
+ cp "$WIREGUARD_CRONTABS_RAM" "$tmp_file" || { rm -f "$tmp_file"; return 1; }
+
+ {
+ printf '%s\n' "$WIREGUARD_CRON_MARK"
+ printf '*/%s * * * * %s >/dev/null 2>&1\n' "$interval" "$WIREGUARD_WATCHDOG_BIN"
+ } >>"$tmp_file"
+
+ cp "$tmp_file" "$WIREGUARD_CRONTABS_RAM"
+ rm -f "$tmp_file"
+ reload_cron
+ echo "- WireGuard watchdog scheduled every $interval minute(s) in RAM"
+}
+
+start() {
+ if [ -n "$(fw_printenv -n wg_privkey 2>/dev/null)" ]; then
+ echo "Enabling WireGuard Watchdog"
+ wireguard_watchdog_install_cron || exit 1
+ fi
+}
+
+stop() {
+ echo "Disabling WireGuard Watchdog"
+ wireguard_watchdog_remove_cron
+}
+
+if [ "${WIREGUARD_LIB_ONLY:-0}" = "1" ]; then
+ return 0 2>/dev/null || exit 0
+fi
+
+case "$1" in
+ force) start ;;
+ start) start ;;
+ stop) stop ;;
+ restart) stop && start ;;
+ *)
+ echo "Usage: $0 {force|start|stop|restart}"
+ exit 1
+ ;;
+esac
+
+exit 0
diff --git a/general/overlay/usr/sbin/wireguard b/general/overlay/usr/sbin/wireguard
index ad257ec0b5..f8d68491d7 100755
--- a/general/overlay/usr/sbin/wireguard
+++ b/general/overlay/usr/sbin/wireguard
@@ -1,8 +1,9 @@
#!/bin/sh
+ip link del dev wg0 2>/dev/null || true
+
modprobe wireguard || { echo "Error: Failed to load wireguard module." >&2; exit 1; }
ip link add dev wg0 type wireguard || { echo "Error: Failed to create wg0 interface." >&2; exit 1; }
-
WG_PRIVKEY="$(fw_printenv -n wg_privkey)"
WG_PRESHARED_KEY="$(fw_printenv -n wg_sharkey)"
( echo "#"
@@ -16,8 +17,7 @@ WG_PRESHARED_KEY="$(fw_printenv -n wg_sharkey)"
[ -n "$WG_PRESHARED_KEY" ] && echo "PresharedKey = $WG_PRESHARED_KEY"
echo "AllowedIPs = $(fw_printenv -n wg_allowed)"
echo "#"
-) >>/tmp/wireguard.conf
-
+) > /tmp/wireguard.conf
wg setconf wg0 /tmp/wireguard.conf || { echo "Error: Failed to apply wireguard configuration." >&2; exit 1; }
wg_address="$(fw_printenv -n wg_address)"
if [ -z "$wg_address" ]; then
diff --git a/general/overlay/usr/sbin/wireguard-watchdog b/general/overlay/usr/sbin/wireguard-watchdog
new file mode 100644
index 0000000000..f5b82e7aba
--- /dev/null
+++ b/general/overlay/usr/sbin/wireguard-watchdog
@@ -0,0 +1,149 @@
+#!/bin/sh
+
+wireguard_enabled="true"
+wireguard_watchdog="true"
+
+WIREGUARD_INTERFACE="wg0"
+LOCK_DIR="/run/wireguard-watchdog.lock"
+STATE_FILE="/run/wireguard-watchdog.state"
+
+acquire_lock() {
+ if mkdir "$LOCK_DIR" 2>/dev/null; then
+ trap 'rmdir "$LOCK_DIR" 2>/dev/null' EXIT INT TERM
+ return 0
+ fi
+ exit 0
+}
+
+load_state() {
+ zero_handshake_since=0
+ [ -r "$STATE_FILE" ] && . "$STATE_FILE"
+}
+
+save_state() {
+ local tmp_file
+ tmp_file=$(mktemp) || return 1
+ {
+ printf 'zero_handshake_since=%s\n' "${zero_handshake_since:-0}"
+ } >"$tmp_file"
+ cp "$tmp_file" "$STATE_FILE"
+ rm -f "$tmp_file"
+}
+
+log_watchdog() {
+ logger -t wireguard-watchdog "$1"
+}
+
+restart_wireguard() {
+ local reason="$1"
+ log_watchdog "$reason; restarting $WIREGUARD_INTERFACE"
+ zero_handshake_since=0
+ save_state
+ /etc/init.d/S98wireguard start >/dev/null 2>&1
+}
+
+watchdog_interval_seconds() {
+ local cron_line minutes=2
+
+ cron_line=$(grep "wireguard-watchdog" /run/cron/crontabs/root 2>/dev/null)
+ if [ -n "$cron_line" ]; then
+ minutes=$(echo "$cron_line" | awk '{print $1}' | tr -d '*/')
+ fi
+
+ case "$minutes" in
+ '' | *[!0-9]* | 0) minutes=2 ;;
+ esac
+ [ "$minutes" -gt 59 ] && minutes=59
+
+ echo $((minutes * 60))
+}
+
+watchdog_stale_seconds() {
+ local derived interval keepalive
+
+ interval=$(watchdog_interval_seconds)
+ keepalive=$(wg show "$WIREGUARD_INTERFACE" persistent-keepalive 2>/dev/null | awk '{print $2}' | head -n 1)
+
+ case "$keepalive" in
+ '' | *[!0-9]*) keepalive=0 ;;
+ esac
+
+ if [ "$keepalive" -gt 0 ]; then
+ derived=$((keepalive * 6))
+ else
+ derived=0
+ fi
+
+ [ "$derived" -lt 180 ] && derived=180
+ [ "$derived" -lt $((interval * 2)) ] && derived=$((interval * 2))
+ echo "$derived"
+}
+
+latest_handshake_epoch() {
+ local latest peer ts
+ latest=0
+ while read -r peer ts; do
+ [ -n "$peer" ] || continue
+ case "$ts" in
+ '' | *[!0-9]*) ts=0 ;;
+ esac
+ [ "$ts" -gt "$latest" ] && latest=$ts
+ done </dev/null)
+EOF
+ echo "$latest"
+}
+
+has_wireguard_peers() {
+ wg show "$WIREGUARD_INTERFACE" peers 2>/dev/null | grep -q .
+}
+
+main() {
+ local now latest stale_threshold handshake_age
+
+ acquire_lock
+
+ [ "true" = "$wireguard_enabled" ] || exit 0
+ [ "true" = "$wireguard_watchdog" ] || exit 0
+
+ load_state
+
+ if ! ip link show "$WIREGUARD_INTERFACE" 2>/dev/null | grep -q 'UP'; then
+ restart_wireguard "WireGuard interface $WIREGUARD_INTERFACE is down or missing"
+ exit $?
+ fi
+
+ if ! has_wireguard_peers; then
+ restart_wireguard "WireGuard interface $WIREGUARD_INTERFACE has no peers"
+ exit $?
+ fi
+
+ stale_threshold=$(watchdog_stale_seconds)
+ latest=$(latest_handshake_epoch)
+
+ if [ "$latest" -eq 0 ]; then
+ now=$(date +%s)
+ if [ "${zero_handshake_since:-0}" -eq 0 ]; then
+ zero_handshake_since=$now
+ save_state
+ exit 0
+ fi
+
+ if [ $((now - zero_handshake_since)) -ge "$stale_threshold" ]; then
+ restart_wireguard "WireGuard has not completed a handshake for ${stale_threshold}s"
+ exit $?
+ fi
+ exit 0
+ fi
+
+ zero_handshake_since=0
+ save_state
+
+ now=$(date +%s)
+ handshake_age=$((now - latest))
+ if [ "$handshake_age" -gt "$stale_threshold" ]; then
+ restart_wireguard "WireGuard handshake is stale (${handshake_age}s > ${stale_threshold}s)"
+ fi
+}
+
+main "$@"
]