From a0ee657a02f13f7f82aa4b9280aa7967da071b92 Mon Sep 17 00:00:00 2001 From: Koda YeenBean Date: Wed, 15 Jul 2026 19:39:51 +0100 Subject: [PATCH] Added Release Pipeline (#29) Reviewed-on: https://git.ascheu.de/Koda/OpenUniFi/pulls/29 --- .gitea/release-targets.json | 11 ++++ .gitea/workflows/release.yaml | 101 ++++++++++++++++++++++++++++++++++ README.md | 26 +++++++++ scripts/ci/build-release.sh | 72 ++++++++++++++++++++++++ scripts/ci/gitea-release.sh | 42 ++++++++++++++ 5 files changed, 252 insertions(+) create mode 100644 .gitea/release-targets.json create mode 100644 .gitea/workflows/release.yaml create mode 100755 scripts/ci/build-release.sh create mode 100755 scripts/ci/gitea-release.sh diff --git a/.gitea/release-targets.json b/.gitea/release-targets.json new file mode 100644 index 0000000..f58a4e4 --- /dev/null +++ b/.gitea/release-targets.json @@ -0,0 +1,11 @@ +{ + "openwrt_release": "25.12.1", + "version": { "major": 0, "minor": 4 }, + "targets": [ + { "architecture": "x64", "target": "x86", "subtarget": "64" }, + { "architecture": "x86", "target": "x86", "subtarget": "generic" }, + { "architecture": "arm64", "target": "armsr", "subtarget": "armv8" }, + { "architecture": "arm32", "target": "armsr", "subtarget": "armv7" }, + { "architecture": "mpc85xx", "target": "mpc85xx", "subtarget": "p1020" } + ] +} diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..2568743 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,101 @@ +name: Build and publish release + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: openunifi-release + cancel-in-progress: false + +permissions: + code: read + releases: write + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.release.outputs.matrix }} + version: ${{ steps.release.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install metadata dependencies + run: sudo apt-get update && sudo apt-get install --yes jq + - name: Read release configuration + id: release + shell: bash + run: | + set -euo pipefail + major="$(jq -er '.version.major | select(type == "number" and floor == . and . >= 0)' .gitea/release-targets.json)" + minor="$(jq -er '.version.minor | select(type == "number" and floor == . and . >= 0)' .gitea/release-targets.json)" + tag_prefix="v${major}.${minor}." + latest_patch=-1 + while IFS= read -r tag; do + candidate="${tag#"$tag_prefix"}" + if [[ $candidate =~ ^(0|[1-9][0-9]*)$ ]] && (( candidate > latest_patch )); then + latest_patch=$candidate + fi + done < <(git tag --list "${tag_prefix}*") + patch=$((latest_patch + 1)) + version="${major}.${minor}.${patch}" + matrix="$(jq -c '{include: .targets}' .gitea/release-targets.json)" + jq -e '.targets | length > 0' .gitea/release-targets.json >/dev/null + printf 'version=%s\n' "$version" >>"$GITHUB_OUTPUT" + printf 'matrix=%s\n' "$matrix" >>"$GITHUB_OUTPUT" + + create-release: + needs: metadata + runs-on: ubuntu-latest + outputs: + release-id: ${{ steps.create.outputs.release-id }} + steps: + - uses: actions/checkout@v4 + - name: Install release dependencies + run: sudo apt-get update && sudo apt-get install --yes curl jq + - name: Create draft release + id: create + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + VERSION: ${{ needs.metadata.outputs.version }} + run: | + release_id="$(scripts/ci/gitea-release.sh create)" + printf 'release-id=%s\n' "$release_id" >>"$GITHUB_OUTPUT" + + build: + needs: [metadata, create-release] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.metadata.outputs.matrix) }} + steps: + - uses: actions/checkout@v4 + - name: Install SDK build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes build-essential clang flex bison g++ gawk gettext git libncurses-dev libssl-dev python3 rsync unzip zlib1g-dev file wget patch time curl jq zstd + - name: Build ${{ matrix.architecture }} package + env: + ARCHITECTURE: ${{ matrix.architecture }} + VERSION: ${{ needs.metadata.outputs.version }} + run: scripts/ci/build-release.sh .gitea/release-targets.json "$ARCHITECTURE" "$VERSION" dist + - name: Upload package to draft release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + RELEASE_ID: ${{ needs.create-release.outputs.release-id }} + ASSET: dist/openUniFi-${{ matrix.architecture }}-${{ needs.metadata.outputs.version }}.apk + run: scripts/ci/gitea-release.sh upload + + publish-release: + needs: [metadata, create-release, build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Publish completed release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + RELEASE_ID: ${{ needs.create-release.outputs.release-id }} + run: scripts/ci/gitea-release.sh publish diff --git a/README.md b/README.md index b3fa1ee..5962c81 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,32 @@ make -f Makefile.standalone Contributors and AI agents should read [`AGENTS.md`](AGENTS.md) for the concise architecture map, invariants, and validation checklist. +## Automated releases + +Every push to `main` runs `.gitea/workflows/release.yaml`. It builds with pinned +OpenWrt SDKs and publishes `openUniFi--.apk` for x64, +x86, arm64, arm32, and mpc85xx. The workflow uses Gitea's built-in +`GITEA_TOKEN`; repository Actions settings must allow it `write` access to +releases. A failed target leaves the release as a draft rather than publishing +an incomplete set. + +Targets and the pinned OpenWrt release live in `.gitea/release-targets.json`. +Add an object with a unique `architecture` label and a valid OpenWrt +`target`/`subtarget` pair to extend the matrix. The generic ARM entries select +ARMv7 and ARMv8 ABIs; use a device-specific OpenWrt target when necessary. + +Releases use the next available `MAJOR.MINOR.PATCH` tag. `MAJOR` and `MINOR` +live in the release config; the workflow finds the highest existing +`vMAJOR.MINOR.PATCH` tag and increments its patch, starting at `0` when a new +major or minor line is introduced. For example, changing the config from `0.4` +to `0.5` makes the next release `0.5.0`. Release runs are serialized so two +merges cannot select the same patch version. + +Alternatives are Conventional Commits with a semantic-release tool (best when +not every merge must release), or a manually maintained `VERSION` file bumped +in every pull request (simple, but a forgotten bump blocks the release). Change +`major` for incompatible changes and `minor` for compatible features. + ## Changing Compiler Settings ```shell diff --git a/scripts/ci/build-release.sh b/scripts/ci/build-release.sh new file mode 100755 index 0000000..2148049 --- /dev/null +++ b/scripts/ci/build-release.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 4 ]]; then + echo "usage: $0 CONFIG ARCHITECTURE VERSION OUTPUT_DIR" >&2 + exit 2 +fi + +config=$1 +architecture=$2 +version=$3 +output_dir=$4 +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) + +[[ $architecture =~ ^[a-zA-Z0-9_-]+$ ]] || { echo "invalid architecture name: $architecture" >&2; exit 2; } +[[ $version =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { echo "invalid semantic version: $version" >&2; exit 2; } + +matches=$(jq --arg architecture "$architecture" '[.targets[] | select(.architecture == $architecture)] | length' "$config") +[[ $matches -eq 1 ]] || { echo "architecture must occur exactly once in $config: $architecture" >&2; exit 2; } +openwrt_release=$(jq -er '.openwrt_release' "$config") +target=$(jq -er --arg architecture "$architecture" '.targets[] | select(.architecture == $architecture) | .target' "$config") +subtarget=$(jq -er --arg architecture "$architecture" '.targets[] | select(.architecture == $architecture) | .subtarget' "$config") + +download_base="https://downloads.openwrt.org/releases/${openwrt_release}/targets/${target}/${subtarget}" +sdk_archive=$(curl -fsSL "${download_base}/" | grep -o "openwrt-sdk-${openwrt_release}-[^\"]*\.tar\.zst" | head -n 1) +[[ -n $sdk_archive ]] || { echo "no SDK found at $download_base" >&2; exit 1; } + +work_dir="${RUNNER_TEMP:-/tmp}/openunifi-${architecture}-${version}" +rm -rf -- "$work_dir" +mkdir -p -- "$work_dir" "$repo_root/$output_dir" +curl -fL --retry 3 --output "$work_dir/$sdk_archive" "$download_base/$sdk_archive" +expected_sha=$(curl -fsSL "$download_base/sha256sums" | awk -v archive="$sdk_archive" '{ name=$2; sub(/^\*/, "", name); if (name == archive) { print $1; exit } }') +[[ -n $expected_sha ]] || { echo "no checksum found for $sdk_archive" >&2; exit 1; } +printf '%s %s\n' "$expected_sha" "$work_dir/$sdk_archive" | sha256sum --check + +tar --zstd -xf "$work_dir/$sdk_archive" -C "$work_dir" +sdk_dir=$(find "$work_dir" -mindepth 1 -maxdepth 1 -type d -name 'openwrt-sdk-*' -print -quit) +[[ -n $sdk_dir ]] || { echo "SDK directory was not extracted" >&2; exit 1; } + +cd "$sdk_dir" +./scripts/feeds update base packages +./scripts/feeds install mbedtls uci usteer +mkdir -p package/openuf +(cd "$repo_root" && tar --exclude=.git --exclude="$output_dir" -cf - .) | (cd package/openuf && tar -xf -) +sed -i -E "s/^(PKG_VERSION[[:space:]]*:?=[[:space:]]*).*/\\1${version}/" package/openuf/Makefile + +# Release SDKs remember the package set used to create the SDK. Neutralize +# those baked-in defaults so this job builds openuf and its dependency closure, +# rather than every package and kernel module available for the target. +sed -i -E '/^config PACKAGE_/,/^$/ s/^([[:space:]]*)default [ym]$/\1default n/' Config-build.in +for symbol in TARGET_MULTI_PROFILE TARGET_ALL_PROFILES TARGET_PER_DEVICE_ROOTFS ALL_NONSHARED ALL_KMODS ALL BUILDBOT; do + sed -i -E "/^config ${symbol}$/,/^$/ s/^([[:space:]]*)default y$/\\1default n/" Config.in Config-build.in +done +printf '%s\n' \ + '# CONFIG_ALL is not set' \ + '# CONFIG_ALL_KMODS is not set' \ + '# CONFIG_ALL_NONSHARED is not set' \ + '# CONFIG_TARGET_MULTI_PROFILE is not set' \ + '# CONFIG_TARGET_ALL_PROFILES is not set' \ + 'CONFIG_PACKAGE_openuf=m' >.config +make defconfig +make -j"$(nproc)" package/openuf/compile + +mapfile -t packages < <(find bin/packages -type f -name "openuf-${version}-*.apk") +if [[ ${#packages[@]} -ne 1 ]]; then + echo "expected one openuf package, found ${#packages[@]}" >&2 + printf '%s\n' "${packages[@]}" >&2 + exit 1 +fi +asset="$repo_root/$output_dir/openUniFi-${architecture}-${version}.apk" +cp -- "${packages[0]}" "$asset" +sha256sum "$asset" diff --git a/scripts/ci/gitea-release.sh b/scripts/ci/gitea-release.sh new file mode 100755 index 0000000..090106d --- /dev/null +++ b/scripts/ci/gitea-release.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +action=${1:-} +: "${GITEA_TOKEN:?GITEA_TOKEN is required}" +: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +api="${GITHUB_SERVER_URL%/}/api/v1/repos/${GITHUB_REPOSITORY}" +auth_header="Authorization: token ${GITEA_TOKEN}" + +case "$action" in + create) + : "${VERSION:?VERSION is required}" + tag="v${VERSION}" + if response=$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag" 2>/dev/null); then + jq -er '.id' <<<"$response" + exit 0 + fi + openwrt_release=$(jq -er '.openwrt_release' .gitea/release-targets.json) + payload=$(jq -n --arg tag "$tag" --arg version "$VERSION" --arg sha "${GITHUB_SHA:-main}" --arg openwrt "$openwrt_release" '{tag_name: $tag, target_commitish: $sha, name: ("openUniFi " + $version), body: ("Automated multi-architecture build for OpenWrt " + $openwrt + "."), draft: true, prerelease: false}') + curl -fsS -X POST -H "$auth_header" -H 'Content-Type: application/json' --data "$payload" "$api/releases" | jq -er '.id' + ;; + upload) + : "${RELEASE_ID:?RELEASE_ID is required}" + : "${ASSET:?ASSET is required}" + [[ -f $ASSET ]] || { echo "asset not found: $ASSET" >&2; exit 1; } + asset_name=$(basename -- "$ASSET") + old_asset_id=$(curl -fsS -H "$auth_header" "$api/releases/$RELEASE_ID/assets" | jq -r --arg name "$asset_name" '.[] | select(.name == $name) | .id' | head -n 1) + if [[ -n $old_asset_id ]]; then + curl -fsS -X DELETE -H "$auth_header" "$api/releases/$RELEASE_ID/assets/$old_asset_id" + fi + curl -fsS -X POST -H "$auth_header" -F "attachment=@${ASSET}" "$api/releases/$RELEASE_ID/assets?name=$asset_name" >/dev/null + ;; + publish) + : "${RELEASE_ID:?RELEASE_ID is required}" + curl -fsS -X PATCH -H "$auth_header" -H 'Content-Type: application/json' --data '{"draft":false}' "$api/releases/$RELEASE_ID" >/dev/null + ;; + *) + echo "usage: $0 {create|upload|publish}" >&2 + exit 2 + ;; +esac