Files
shater/.github/workflows/lx-release.yml
T
Leadaxe 5e345ffe48 ci(lx-release): durable musl-toolchain mirror as release asset (SPEC 023)
snapshot.debian.org intermittently 503s during the musl sysroot build and
blocks releases (v1.14.0-lx.2-rc.1 failed twice on it). actions/cache also
misses across tag builds (ref-scoping). Add a producer workflow that uploads
the built toolchain to a musl-toolchain-cache release, and a restore step in
lx-release.yml that pulls it on cache-miss before falling back to
snapshot.debian.org. Both workflows are lx-owned; zero upstream diff.
2026-07-02 19:34:07 +03:00

475 lines
22 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: lx-release
# Cross-builds the drop-in `sing-box` binary for all platforms and publishes a
# GitHub Release with archives + checksums. Triggered by pushing a tag like
# v1.13.13-lx.1, or manually via workflow_dispatch. See SPECS/004.
on:
push:
tags: ['v*-lx.*']
workflow_dispatch:
inputs:
tag:
description: 'Release tag, e.g. v1.13.13-lx.1 (created at the current ref if missing)'
required: true
permissions:
contents: write
# Build tags are owned by Makefile.lx (single source of truth). The desktop/CLI build
# uses its LX_TAGS default (which KEEPS with_clash_api — CLI binaries are driven by
# external dashboards over the Clash REST API); the Android AAR uses build_libbox's own
# tag set (which DROPS with_clash_api — LxBox uses the native CommandClient). The two
# sets diverge by design. `make -f Makefile.lx -s lx-print-tags` prints the desktop set
# for the release notes so nothing is duplicated here.
jobs:
build:
name: build ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# Linux lives in the build_linux_musl job (static musl + naive, for
# routers). See SPECS/006. This job covers the purego/native targets
# where libdl is a non-issue — plus targets cronet can't reach at all
# (no_naive: pure-Go static, naive/purego dropped).
- { goos: darwin, goarch: amd64 }
- { goos: darwin, goarch: arm64 }
- { goos: windows, goarch: amd64, ext: .exe }
- { goos: windows, goarch: arm64, ext: .exe }
# Windows 7 (32-bit): built with a Win7-patched Go, and without
# with_naive_outbound (cronet-go has no windows/386 build). See SPECS/004.
- { goos: windows, goarch: "386", ext: .exe, legacy_win7: true, legacy_name: windows-7 }
# Big-endian MIPS routers (OpenWrt mips_24kc, e.g. Atheros AR93xx) — issue #6.
# Chromium has no big-endian MIPS toolchain, so the musl/naive path is
# impossible here; a pure-Go CGO_ENABLED=0 build is statically linked anyway
# (runs on musl), it just drops naive/cronet (with_purego doesn't compile on
# mips either — purego has no mips port).
- { goos: linux, goarch: mips, gomips: softfloat, no_naive: true }
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
if: ${{ ! matrix.legacy_win7 }}
with:
go-version-file: go.mod
check-latest: true
# Win7 needs a Go toolchain that still targets Windows 7: setup_go_for_windows7.sh
# fetches stock Go and applies MetaCubeX/go patches reverting the Win7 removals.
- name: Cache Win7 Go toolchain
if: matrix.legacy_win7
id: cache-go-win7
uses: actions/cache@v4
with:
path: ~/go/go_win7
key: go_win7_${{ hashFiles('.github/setup_go_for_windows7.sh') }}
- name: Build Win7 Go toolchain
if: matrix.legacy_win7 && steps.cache-go-win7.outputs.cache-hit != 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash .github/setup_go_for_windows7.sh
- name: Use Win7 Go toolchain
if: matrix.legacy_win7
run: |
echo "PATH=$HOME/go/go_win7/bin:$PATH" >> "$GITHUB_ENV"
echo "GOROOT=$HOME/go/go_win7" >> "$GITHUB_ENV"
- name: Resolve version
id: ver
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOMIPS: ${{ matrix.gomips }}
run: |
TAGS="$(make -f Makefile.lx -s lx-print-tags)"
if [ "${{ matrix.legacy_win7 }}" = "true" ]; then
# cronet-go (with_naive_outbound) has no windows/386 build — drop it.
TAGS="${TAGS/with_naive_outbound,/}"
fi
if [ "${{ matrix.no_naive }}" = "true" ]; then
# No cronet for this target at all: drop naive AND its purego loader.
TAGS="${TAGS/with_naive_outbound,/}"
TAGS="${TAGS/with_purego,/}"
fi
make -f Makefile.lx lx-build \
LX_TAGS="$TAGS" \
LX_VERSION="${{ steps.ver.outputs.version }}" \
LX_OUTPUT="sing-box${{ matrix.ext }}"
- name: Package
run: |
NAME="sing-box-${{ steps.ver.outputs.version }}-${{ matrix.goos }}-${{ matrix.goarch }}"
if [ -n "${{ matrix.gomips }}" ]; then
NAME="${NAME}-${{ matrix.gomips }}"
fi
if [ -n "${{ matrix.legacy_name }}" ]; then
NAME="${NAME}-legacy-${{ matrix.legacy_name }}"
fi
mkdir -p "stage/$NAME" dist
cp "sing-box${{ matrix.ext }}" "stage/$NAME/"
cp LICENSE LICENSING.md README.md "stage/$NAME/" 2>/dev/null || true
if [ "${{ matrix.goos }}" = "windows" ]; then
(cd stage && zip -qr "../dist/$NAME.zip" "$NAME")
else
tar -C stage -czf "dist/$NAME.tar.gz" "$NAME"
fi
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.goos }}-${{ matrix.goarch }}
path: dist/*
if-no-files-found: error
# Static musl Linux builds for routers (AsusWRT Merlin, OpenWrt, Keenetic).
# The desktop `build` job ships Linux via with_purego, which pulls a dynamic
# libdl.so.2 dependency (purego's //go:cgo_import_dynamic) and won't load on
# musl. Here we mirror upstream build.yml: clone cronet-go, fetch the Chromium
# musl toolchain via its cmd/build-naive, and build CGO_ENABLED=1 with
# `with_musl` — libcronet.a is linked statically and naive is preserved.
# See SPECS/006.
build_linux_musl:
name: build linux-musl/${{ matrix.asset }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { arch: amd64, asset: linux-amd64 }
- { arch: arm64, asset: linux-arm64 }
- { arch: arm, goarm: "7", asset: linux-armv7 }
- { arch: mipsle, gomips: softfloat, asset: linux-mipsle-softfloat }
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Resolve version
id: ver
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
# cronet-go carries the build-naive tool + the naiveproxy/src Chromium
# toolchain sources. Pin to the same commit go.mod depends on.
- name: Clone cronet-go
run: |
set -xeuo pipefail
CRONET_GO_VERSION="$(cat .github/CRONET_GO_VERSION)"
git init ~/cronet-go
git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git
git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION"
git -C ~/cronet-go checkout FETCH_HEAD
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Regenerate Debian keyring
run: |
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
# generate_keyring.sh fetches Debian archive keys from keyservers,
# which can also be flaky — retry with backoff. (lx CI)
n=0; max=4; delay=20
until GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh; do
n=$((n+1))
if [ "$n" -ge "$max" ]; then
echo "keyring regen failed after $max attempts"; exit 1
fi
echo "keyring regen attempt $n failed; retrying in ${delay}s"
sleep "$delay"; delay=$((delay*2))
done
- name: Cache Chromium toolchain
uses: actions/cache@v4
with:
path: |
~/cronet-go/naiveproxy/src/third_party/llvm-build/
~/cronet-go/naiveproxy/src/gn/out/
~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/
~/cronet-go/naiveproxy/src/out/sysroot-build/
key: chromium-toolchain-musl-${{ matrix.arch }}-${{ hashFiles('.github/CRONET_GO_VERSION') }}
# Second source, between actions/cache and snapshot.debian.org: our own
# durable mirror (release `musl-toolchain-cache`, produced by
# lx-musl-toolchain-mirror.yml). Restores on an actions/cache miss —
# including the common ref-scoping miss where a tag build can't see another
# tag's cache — so a snapshot.debian.org outage no longer blocks releases.
# SPEC 023. If the mirror also misses, the download step below falls back to
# snapshot.debian.org exactly as before.
- name: Restore musl toolchain from lx mirror
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -xeuo pipefail
SRC=~/cronet-go/naiveproxy/src
# actions/cache hit already populated the tree — nothing to do.
if [ -d "$SRC/out/sysroot-build" ] && [ -d "$SRC/third_party/llvm-build" ]; then
echo "actions/cache hit — skipping lx mirror"; exit 0
fi
CRONET_GO_VERSION="$(cat .github/CRONET_GO_VERSION)"
ASSET="toolchain-${{ matrix.arch }}-${CRONET_GO_VERSION}.tar.zst"
if gh release download musl-toolchain-cache --repo "$GITHUB_REPOSITORY" \
--pattern "$ASSET" --dir /tmp/lxtc 2>/dev/null; then
tar --zstd -C "$SRC" -xf "/tmp/lxtc/$ASSET"
echo "restored toolchain from lx mirror ($ASSET)"
else
echo "lx mirror miss ($ASSET) — falling back to snapshot.debian.org"
fi
- name: Download Chromium musl toolchain
run: |
set -xeuo pipefail
cd ~/cronet-go
# The toolchain download pulls sysroot .deb packages from
# snapshot.debian.org, which intermittently 503s ("No healthy
# backends"). get-clang.sh already retries internally but back-to-back,
# so a whole outage window fails all attempts. Retry the step with a
# growing backoff to ride out a transient mirror outage. (lx CI)
n=0; max=5; delay=30
until go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl download-toolchain; do
n=$((n+1))
if [ "$n" -ge "$max" ]; then
echo "toolchain download failed after $max attempts"; exit 1
fi
echo "toolchain download attempt $n failed; retrying in ${delay}s (snapshot.debian.org may be flaky)"
sleep "$delay"; delay=$((delay*2))
done
- name: Set Chromium toolchain environment
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl env >> "$GITHUB_ENV"
- name: Build (musl + naive, static)
env:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
GOMIPS: ${{ matrix.gomips }}
run: |
set -xeuo pipefail
# LX_TAGS is the single source of truth (Makefile.lx). Swap the purego
# cronet loader for the static musl one; with_naive_outbound stays.
TAGS="$(make -f Makefile.lx -s lx-print-tags)"
TAGS="${TAGS/with_purego/with_musl}"
mkdir -p dist
go build -v -trimpath -tags "$TAGS" \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ steps.ver.outputs.version }}' -checklinkname=0 -s -w -buildid=" \
-o dist/sing-box ./cmd/sing-box
- name: Verify static (no libdl)
run: |
set -xeuo pipefail
file dist/sing-box
file dist/sing-box | grep -q "statically linked"
if strings -a dist/sing-box | grep -q "libdl.so.2"; then
echo "FAIL: libdl.so.2 reference present — not a static musl build"; exit 1
fi
echo "OK: statically linked, no libdl.so.2"
- name: Package
run: |
NAME="sing-box-${{ steps.ver.outputs.version }}-${{ matrix.asset }}"
mkdir -p "stage/$NAME"
cp dist/sing-box "stage/$NAME/"
cp LICENSE LICENSING.md README.md "stage/$NAME/" 2>/dev/null || true
tar -C stage -czf "dist/$NAME.tar.gz" "$NAME"
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.asset }}
path: dist/*.tar.gz
if-no-files-found: error
build_android:
name: build android (libbox.aar)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Setup Android NDK
id: setup-ndk
uses: nttld/setup-ndk@v1
with:
ndk-version: r28
- name: Setup OpenJDK 17
run: sudo apt-get update && sudo apt-get install -y openjdk-17-jdk-headless
- name: Resolve version
id: ver
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
# build_libbox stamps Libbox.version() from `git describe` — ensure the tag exists.
git tag "$TAG" -f
- name: Build libbox.aar (with_xhttp + with_awg baked in by build_libbox)
run: |
make lib_install
export PATH="$PATH:$(go env GOPATH)/bin"
make lib_android
env:
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
- name: Package AARs
run: |
mkdir -p dist
V="${{ steps.ver.outputs.version }}"
cp libbox.aar "dist/libbox-$V.aar"
cp libbox-legacy.aar "dist/libbox-legacy-$V.aar"
- uses: actions/upload-artifact@v4
with:
name: dist-android
path: dist/*
if-no-files-found: error
release:
name: publish release
needs: [build, build_linux_musl, build_android]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Resolve tag
id: ver
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
# Derive the upstream base version for the release notes instead of hardcoding it
# (it drifted to alpha.35 across rc.14/15/16 and had to be hand-fixed each time).
# Primary source: the nearest reachable upstream alpha tag in HEAD's ancestry
# (`git describe`). This reads the commit GRAPH, not commit-message text, so it's
# correct even when a merge subject omits the alpha number — e.g. alpha.37 was
# merged in a commit titled "Merge upstream/testing (bump version, fix linux ping)"
# with no "alpha.37" in it, which the subject-grep fallback misses (shipped rc.17/18
# notes as alpha.36). Fallback: scan "Merge upstream" subjects for the highest
# alpha.NN. Last resort: a generic label so notes never carry a stale hardcoded version.
#
# NOTE: actions/checkout only fetches THIS repo's tags, not upstream's. The
# v1.14.0-alpha.* tags are SagerNet/sing-box tags, so without this fetch `git
# describe` finds nothing in CI and silently drops to the weaker subject-grep (which
# is why CI kept resolving alpha.36 while a local clone with upstream tags gets 37).
# Fetch the upstream alpha tags first so the primary `git describe` path works.
git remote add upstream https://github.com/SagerNet/sing-box.git 2>/dev/null || true
git fetch --no-tags upstream 'refs/tags/v1.14.0-alpha.*:refs/tags/v1.14.0-alpha.*' 2>/dev/null || true
BASE="$(git describe --tags --match 'v1.14.0-alpha.*' --abbrev=0 HEAD 2>/dev/null || true)"
if [ -z "$BASE" ]; then
BASE_N="$(git log --grep='Merge upstream' --pretty=%s | grep -oE 'alpha\.[0-9]+' | sed 's/alpha\.//' | sort -n | tail -1)"
[ -n "$BASE_N" ] && BASE="v1.14.0-alpha.${BASE_N}"
fi
[ -z "$BASE" ] && BASE="v1.14.x"
echo "base=$BASE" >> "$GITHUB_OUTPUT"
echo "Resolved upstream base: $BASE"
- name: Checksums
run: (cd dist && sha256sum * > SHA256SUMS && cat SHA256SUMS)
- name: Release notes
run: |
# What's-new for THIS tag comes from docs-lx/lx-changelog.md (single source of
# truth, kept per release) — extract the section for this version, between its
# "#### vX" header and the next "#### ", into a SEPARATE file. It must NOT be
# interpolated through the heredoc below: changelog prose contains backticks and
# $(...) that the shell would execute (command injection from doc text). We splice
# the file in with sed after the heredoc instead.
VERSION="${{ steps.ver.outputs.version }}"
awk -v v="#### v${VERSION}" '
$0==v {f=1; next} /^#### / {f=0} f' docs-lx/lx-changelog.md > changes.md
if [ -z "$(tr -d '[:space:]' < changes.md)" ]; then
echo "See [docs-lx/lx-changelog.md](https://github.com/Leadaxe/sing-box-lx/blob/lx/docs-lx/lx-changelog.md)." > changes.md
fi
cat > notes.md <<EOF
**sing-box-lx ${{ steps.ver.outputs.version }}** — a thin downstream of [sing-box](https://github.com/SagerNet/sing-box) (base **${{ steps.ver.outputs.base }}**, branch \`lx\`).
### What's new in this release
__CHANGES__
### Standing features
- **AmneziaWG 2.0** (\`with_awg\`) — \`wireguard\` endpoint with \`jc/jmin/jmax\`, \`s1\`–\`s4\`, \`h1\`–\`h4\`, \`i1\`–\`i5\`.
- **XHTTP** transport (\`with_xhttp\`) — Xray-compatible "splithttp", composes with Reality (use \`auto\`; \`stream-one\` has a known framing bug).
- **CommandClient extensions** (\`with_lx_command\`) — native libbox gRPC parity for the Clash API dropped from the **Android AAR**: URLTestOutbound, GetRules, GetGroups/GetOutbounds, Connection.Detour, SubscribeDNSQueries. (Desktop/CLI binaries keep \`with_clash_api\` for external dashboards.)
### Binaries
Drop-in \`sing-box\` for **darwin / windows** × {amd64, arm64}, plus a **Windows 7 (32-bit)** legacy build (\`sing-box-${{ steps.ver.outputs.version }}-windows-386-legacy-windows-7.zip\` — built with a Win7-patched Go; without naive/cronet, which has no windows/386 target).
**Linux — static musl builds for routers** (AsusWRT Merlin, OpenWrt, Keenetic): \`linux-amd64\`, \`linux-arm64\`, \`linux-armv7\`, \`linux-mipsle-softfloat\`. These are statically linked (no \`libdl.so.2\`/glibc dependency) and **keep NaïveProxy** — they run on musl routers where the previous dynamic builds failed with \`libdl.so.2: cannot open shared object file\`. See SPECS/006.
**Linux — big-endian MIPS** (OpenWrt \`mips_24kc\`, e.g. Atheros AR93xx): \`linux-mips-softfloat\` — pure-Go static build **without NaïveProxy** (Chromium/cronet has no big-endian MIPS toolchain); everything else matches the desktop tag set.
Each archive contains the \`sing-box\` binary (\`sing-box version\` reports \`${{ steps.ver.outputs.version }}\`). Verify downloads against \`SHA256SUMS\`.
### Android
\`libbox-${{ steps.ver.outputs.version }}.aar\` (+ \`libbox-legacy-…\` for SDK 21) — gomobile build of \`experimental/libbox\` with \`with_xhttp\`+\`with_awg\` enabled, for embedding in an Android app. \`Libbox.version()\` reports the lx version.
### Build tags
Desktop binary: \`$(make -f Makefile.lx -s lx-print-tags)\`
Config reference: [docs-lx/lx-config.md](https://github.com/Leadaxe/sing-box-lx/blob/lx/docs-lx/lx-config.md).
EOF
# Splice the changelog section in place of the placeholder. `sed r` inserts the
# file verbatim (no shell/sed interpretation of its contents), so backticks and
# $(...) in the prose are inert. Then drop the placeholder line itself. Written
# via a temp file (not sed -i, whose flag syntax differs BSD vs GNU).
sed -e '/__CHANGES__/r changes.md' -e '/__CHANGES__/d' notes.md > notes.final.md
mv notes.final.md notes.md
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
run: |
# Pre-release tags (-rc.N / -alpha.N / -beta.N) publish as GitHub
# pre-releases so an unverified build never becomes "Latest". A plain
# vX.Y.Z-lx.N tag (no such suffix) publishes as a normal release.
EXTRA=""
case "${{ steps.ver.outputs.tag }}" in
*-rc.*|*-alpha.*|*-beta.*) EXTRA="--prerelease" ;;
esac
# --repo is REQUIRED: the base-version step adds an `upstream` remote
# (SagerNet/sing-box) so git-describe can see the alpha tags, and without
# --repo `gh` resolves the target repo from the remotes and picks upstream
# → HTTP 403 (the token has no rights there). Pin it to this repo.
gh release create "${{ steps.ver.outputs.tag }}" dist/* \
--repo "${{ github.repository }}" \
--title "sing-box-lx ${{ steps.ver.outputs.version }}" \
--notes-file notes.md \
--target "$GITHUB_SHA" \
$EXTRA