Files
3x-ui/.github/workflows/release.yml
T
MHSanaei ab1a922806 fix(ci): survive transient GitHub 5xx outages in the release workflow
A GitHub API 503 storm failed the release run four attempts in a row:
the mtg-multi latest-release lookup died on every platform (even s390x,
which never packages the sidecar), and the matrix default fail-fast then
cancelled the six healthy builds alongside the one that hit the outage.

Retry every external fetch with backoff (curl --retry-all-errors, wget
--retry-on-http-error, Invoke-* -MaximumRetryCount), scope the mtg-multi
tag lookup to the platforms that actually package the sidecar, disable
fail-fast on the build matrix, and retry the idempotent dev-latest
publish commands. The workflow file itself now triggers the run's path
filters, so editing it exercises the release build immediately instead
of failing silently at the next code push.
2026-07-17 01:06:14 +02:00

399 lines
17 KiB
YAML

name: Release 3X-UI
on:
workflow_dispatch:
push:
branches:
- "**"
tags:
- "v*.*.*"
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.sh"
- "frontend/**"
- "x-ui.service.debian"
- "x-ui.service.arch"
- "x-ui.service.rhel"
- ".github/workflows/release.yml"
pull_request:
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.sh"
- "frontend/**"
- "x-ui.service.debian"
- "x-ui.service.arch"
- "x-ui.service.rhel"
- ".github/workflows/release.yml"
jobs:
build:
permissions:
contents: write
strategy:
# One platform hitting a transient outage must not cancel the other six.
fail-fast: false
matrix:
platform:
- amd64
- arm64
- armv7
- armv6
- 386
- armv5
- s390x
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
check-latest: true
# Frontend dist must be built BEFORE go build — Go's //go:embed
# all:dist directive in internal/web/web.go requires internal/web/dist/ to exist
# at compile time. internal/web/dist/ is .gitignored, so on a fresh CI
# checkout it doesn't exist until vite emits it.
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Build frontend bundle
run: |
npm ci
npm run build
working-directory: frontend
- name: Build 3X-UI
run: |
CURL_RETRY="--retry 5 --retry-all-errors --retry-delay 3"
fetch() { wget -q --tries=5 --waitretry=10 --retry-on-http-error=429,500,502,503 "$@"; }
export CGO_ENABLED=1
export GOOS=linux
export GOARCH=${{ matrix.platform }}
# Use Bootlin prebuilt cross-toolchains (musl 1.2.5 in stable series)
case "${{ matrix.platform }}" in
amd64) BOOTLIN_ARCH="x86-64" ;;
arm64) BOOTLIN_ARCH="aarch64" ;;
armv7) BOOTLIN_ARCH="armv7-eabihf"; export GOARCH=arm GOARM=7 ;;
armv6) BOOTLIN_ARCH="armv6-eabihf"; export GOARCH=arm GOARM=6 ;;
armv5) BOOTLIN_ARCH="armv5-eabi"; export GOARCH=arm GOARM=5 ;;
386) BOOTLIN_ARCH="x86-i686" ;;
s390x) BOOTLIN_ARCH="s390x-z13" ;;
esac
echo "Resolving Bootlin musl toolchain for arch=$BOOTLIN_ARCH (platform=${{ matrix.platform }})"
TARBALL_BASE="https://toolchains.bootlin.com/downloads/releases/toolchains/$BOOTLIN_ARCH/tarballs/"
TARBALL_URL=$(curl -fsSL $CURL_RETRY "$TARBALL_BASE" | grep -oE "${BOOTLIN_ARCH}--musl--stable-[^\"]+\\.tar\\.xz" | sort -r | head -n1)
[ -z "$TARBALL_URL" ] && { echo "Failed to locate Bootlin musl toolchain for arch=$BOOTLIN_ARCH" >&2; exit 1; }
echo "Downloading: $TARBALL_URL"
cd /tmp
curl -fL -sS $CURL_RETRY -o "$(basename "$TARBALL_URL")" "$TARBALL_BASE/$TARBALL_URL"
tar -xf "$(basename "$TARBALL_URL")"
TOOLCHAIN_DIR=$(find . -maxdepth 1 -type d -name "${BOOTLIN_ARCH}--musl--stable-*" | head -n1)
export PATH="$(realpath "$TOOLCHAIN_DIR")/bin:$PATH"
export CC=$(realpath "$(find "$TOOLCHAIN_DIR/bin" -name '*-gcc.br_real' -type f -executable | head -n1)")
[ -z "$CC" ] && { echo "No gcc.br_real found in $TOOLCHAIN_DIR/bin" >&2; exit 1; }
cd -
# Stamp the commit into per-commit (dev channel) builds only; tagged
# stable releases stay unstamped so config.IsDevBuild() returns false.
LDFLAGS="-w -s -linkmode external -extldflags '-static'"
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
LDFLAGS="$LDFLAGS -X github.com/mhsanaei/3x-ui/v3/internal/config.buildCommit=${GITHUB_SHA::8} -X github.com/mhsanaei/3x-ui/v3/internal/config.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
fi
go build -ldflags "$LDFLAGS" -o xui-release -v main.go
file xui-release
ldd xui-release || echo "Static binary confirmed"
mkdir x-ui
cp xui-release x-ui/
cp x-ui.service.debian x-ui/
cp x-ui.service.arch x-ui/
cp x-ui.service.rhel x-ui/
cp x-ui.sh x-ui/
mv x-ui/xui-release x-ui/x-ui
mkdir x-ui/bin
cd x-ui/bin
# Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
if [ "${{ matrix.platform }}" == "amd64" ]; then
fetch ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip
rm -f Xray-linux-64.zip
elif [ "${{ matrix.platform }}" == "arm64" ]; then
fetch ${Xray_URL}Xray-linux-arm64-v8a.zip
unzip Xray-linux-arm64-v8a.zip
rm -f Xray-linux-arm64-v8a.zip
elif [ "${{ matrix.platform }}" == "armv7" ]; then
fetch ${Xray_URL}Xray-linux-arm32-v7a.zip
unzip Xray-linux-arm32-v7a.zip
rm -f Xray-linux-arm32-v7a.zip
elif [ "${{ matrix.platform }}" == "armv6" ]; then
fetch ${Xray_URL}Xray-linux-arm32-v6.zip
unzip Xray-linux-arm32-v6.zip
rm -f Xray-linux-arm32-v6.zip
elif [ "${{ matrix.platform }}" == "386" ]; then
fetch ${Xray_URL}Xray-linux-32.zip
unzip Xray-linux-32.zip
rm -f Xray-linux-32.zip
elif [ "${{ matrix.platform }}" == "armv5" ]; then
fetch ${Xray_URL}Xray-linux-arm32-v5.zip
unzip Xray-linux-arm32-v5.zip
rm -f Xray-linux-arm32-v5.zip
elif [ "${{ matrix.platform }}" == "s390x" ]; then
fetch ${Xray_URL}Xray-linux-s390x.zip
unzip Xray-linux-s390x.zip
rm -f Xray-linux-s390x.zip
fi
rm -f geoip.dat geosite.dat
fetch https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
fetch https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
fetch -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
fetch -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
fetch -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
fetch -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
mv xray xray-linux-${{ matrix.platform }}
# mtg-multi (MTProto sidecar) ships prebuilt release binaries whose
# platform labels match our matrix, so download and unpack the matching
# archive. Only the platforms the fork publishes are packaged — the tag
# lookup lives inside that branch so unpackaged platforms (s390x) never
# depend on it. The tag is resolved from the fork's latest release so it
# never needs bumping here; the token only lifts the API rate limit.
case "${{ matrix.platform }}" in
amd64|arm64|armv7|armv6|386)
MTG_MULTI_VER=$(curl -sfL $CURL_RETRY -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1)
if [ -z "$MTG_MULTI_VER" ]; then echo "could not resolve the latest mtg-multi release tag"; exit 1; fi
MTG_PKG="mtg-multi-${MTG_MULTI_VER#v}-linux-${{ matrix.platform }}"
curl -sfLRO $CURL_RETRY "https://github.com/mhsanaei/mtg-multi/releases/download/${MTG_MULTI_VER}/${MTG_PKG}.tar.gz"
tar -xzf "${MTG_PKG}.tar.gz"
mv "${MTG_PKG}/mtg-multi" "mtg-linux-${{ matrix.platform }}"
rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz"
;;
esac
cd ../..
- name: Package
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
- name: Upload files to Artifacts
uses: actions/upload-artifact@v7
with:
name: x-ui-linux-${{ matrix.platform }}
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
- name: Upload files to GH release
uses: svenstaro/upload-release-action@v2
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
file: x-ui-linux-${{ matrix.platform }}.tar.gz
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
overwrite: true
prerelease: true
# =================================
# Windows Build
# =================================
build-windows:
name: Build for Windows
permissions:
contents: write
strategy:
matrix:
platform:
- amd64
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
check-latest: true
# Frontend dist must be built BEFORE go build — see comment on the
# Linux job above. This step is identical except npm runs on the
# Windows runner here.
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Build frontend bundle
shell: pwsh
run: |
npm ci
npm run build
working-directory: frontend
- name: Install MSYS2
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
update: true
install: >-
mingw-w64-x86_64-gcc
mingw-w64-x86_64-sqlite3
mingw-w64-x86_64-pkg-config
- name: Build 3X-UI for Windows (CGO)
shell: msys2 {0}
run: |
export PATH="/c/hostedtoolcache/windows/go/$(ls /c/hostedtoolcache/windows/go | sort -V | tail -n1)/x64/bin:$PATH"
export CGO_ENABLED=1
export GOOS=windows
export GOARCH=amd64
export CC=x86_64-w64-mingw32-gcc
which go
go version
gcc --version
# Stamp the commit into per-commit (dev channel) builds only.
LDFLAGS="-w -s"
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
LDFLAGS="$LDFLAGS -X github.com/mhsanaei/3x-ui/v3/internal/config.buildCommit=${GITHUB_SHA:0:8} -X github.com/mhsanaei/3x-ui/v3/internal/config.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
fi
go build -ldflags "$LDFLAGS" -o xui-release.exe -v main.go
- name: Copy and download resources
shell: pwsh
run: |
$retry = @{ MaximumRetryCount = 5; RetryIntervalSec = 10 }
mkdir x-ui
Copy-Item xui-release.exe x-ui\x-ui.exe
mkdir x-ui\bin
cd x-ui\bin
# Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip"
Remove-Item geoip.dat, geosite.dat -ErrorAction SilentlyContinue
Invoke-WebRequest @retry -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip.dat"
Invoke-WebRequest @retry -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite.dat"
Invoke-WebRequest @retry -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat" -OutFile "geoip_IR.dat"
Invoke-WebRequest @retry -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat" -OutFile "geosite_IR.dat"
Invoke-WebRequest @retry -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
Invoke-WebRequest @retry -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
Rename-Item xray.exe xray-windows-amd64.exe
# mtg-multi (MTProto sidecar) publishes a prebuilt Windows binary, so
# download and unpack it instead of compiling. The tag tracks the
# fork's latest release so it never needs bumping here.
$MTG_MULTI_VER = (Invoke-RestMethod @retry -Uri "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" -Headers @{ Authorization = "Bearer ${{ secrets.GITHUB_TOKEN }}"; "User-Agent" = "x-ui-release" }).tag_name
if (-not $MTG_MULTI_VER) { throw "could not resolve the latest mtg-multi release tag" }
$MTG_PKG = "mtg-multi-$($MTG_MULTI_VER.TrimStart('v'))-windows-amd64"
curl.exe -sfLRO --retry 5 --retry-all-errors --retry-delay 3 "https://github.com/mhsanaei/mtg-multi/releases/download/$MTG_MULTI_VER/$MTG_PKG.zip"
Expand-Archive -Path "$MTG_PKG.zip" -DestinationPath "mtg-tmp" -Force
Move-Item "mtg-tmp/$MTG_PKG/mtg-multi.exe" "mtg-windows-amd64.exe"
Remove-Item -Recurse -Force "mtg-tmp", "$MTG_PKG.zip"
cd ..
Copy-Item -Path ..\windows_files\* -Destination . -Recurse
cd ..
- name: Package to Zip
shell: pwsh
run: |
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
- name: Upload files to Artifacts
uses: actions/upload-artifact@v7
with:
name: x-ui-windows-amd64
path: ./x-ui-windows-amd64.zip
- name: Upload files to GH release
uses: svenstaro/upload-release-action@v2
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
file: x-ui-windows-amd64.zip
asset_name: x-ui-windows-amd64.zip
overwrite: true
prerelease: true
# =================================
# Rolling dev channel (per-commit)
# =================================
# Publishes/overwrites the build artifacts to a single fixed-tag pre-release
# `dev-latest`, force-moved to the new commit on every push to main. The panel's
# "Dev" update channel installs from this tag. `--latest=false` is load-bearing:
# it keeps releases/latest pointing at the real stable tag, so the stable
# channel is unaffected.
publish-dev:
name: Publish rolling dev release
needs: [build, build-windows]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
# Serialize racing pushes; never cancel an in-flight upload, or the dev
# release could be left with a partial asset set.
concurrency:
group: dev-release
cancel-in-progress: false
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Download all build artifacts
uses: actions/download-artifact@v8
with:
path: dev-artifacts
merge-multiple: true
- name: Publish dev-latest pre-release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMIT: ${{ github.sha }}
run: |
set -e
retry() {
for i in 1 2 3 4 5; do
"$@" && return 0
echo "attempt $i failed: $1" >&2
sleep $((i * 5))
done
return 1
}
short="${COMMIT::8}"
notes="Rolling development build — installs via the panel's Dev update channel.
commit=${COMMIT}
built=$(date -u +%Y-%m-%dT%H:%M:%SZ)
Automated per-commit build from main. Not a stable release."
# Force-move the dev-latest tag to this commit so the release tracks it.
git tag -f dev-latest "${COMMIT}"
retry git push -f origin refs/tags/dev-latest
if gh release view dev-latest >/dev/null 2>&1; then
retry gh release edit dev-latest --prerelease --latest=false \
--title "Dev build ${short}" --notes "${notes}"
else
retry gh release create dev-latest --prerelease --latest=false \
--target "${COMMIT}" --title "Dev build ${short}" --notes "${notes}"
fi
retry gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip --clobber