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 "$@"