Skip to content

build

build #2944

Workflow file for this run

name: build
on:
pull_request:
branches:
- master
schedule:
- cron: '30 22 * * *'
workflow_dispatch:
jobs:
preflight:
name: Preflight
# Guard rail for clones of this repo. A clone pushed to a new repository
# (a private mirror, an internal re-host) inherits this file along with
# its cron, and the full platform matrix — ~2000 runner-minutes a pass —
# then runs on that owner's Actions bill: nightly via the schedule, and
# again on every internal PR they open. Forks are partially covered
# (GitHub disables scheduled workflows there), a fresh repository is not.
#
# The line is drawn where the minutes stop being free:
# - canonical repo: everything runs, unchanged
# - workflow_dispatch: an operator explicitly asked; runs anywhere
# - pull_request on a PUBLIC repo: free minutes (contributor PRs here
# run in this repo's context; within-fork PRs are public by
# construction) — runs
# - anything unattended or billed on a non-canonical repo — skips
if: >-
github.repository == 'OpenIPC/firmware' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && !github.event.repository.private)
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: <pkg>-<ref>.tar.gz e.g. majestic-webui-dist.tar.gz
# - dot: <pkg>.<ref>.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() { # <tag> [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() { # <tag> <file>...
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 (<board>)" 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]
# always() so the gate still reports when a needed job fails -- but not on a
# run whose preflight the guard above deliberately skipped, where it would
# read preflight=skipped and turn a saved build into a red run. Upstream the
# added disjunct is true, so this reduces to always().
if: >-
always() && (github.repository == 'OpenIPC/firmware' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && !github.event.repository.private))
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