Files
3x-ui/.github/workflows/release.yml
T
Sanaei d0edbcec81 feat(xray): update xray-core to v26.9.9 and follow the udpHop move
Bump xtls/xray-core to 52a412d9e2f5 (v26.9.9) and the three binary pins in
DockerInit.sh and release.yml in lockstep.

Upstream moved UDP port hopping out of finalmask.quicParams.udpHop and into a
standalone "udphop" UDP mask with a different shape (mode / interval /
remotePorts / remoteIPs). The old key is gone from QuicParams, and since the
config loader ignores unknown fields it is now silently dropped rather than
rejected — port hopping just stops.

The panel adapts where that key was live:

- Both link importers rebuilt quicParams.udpHop from the standard mport param,
  so an imported hysteria2 link produced an outbound that no longer hops. They
  now emit a udphop mask in intervalremote mode, which is what the old key did.
  The mode is required: UDPHop.Build() rejects an empty or unknown one.
- validFinalMaskUDPTypes and UdpMaskTypeSchema learn "udphop", otherwise the Go
  link generator strips the mask from every link and sub, and Zod strips it on
  the next form round trip.
- mport generation (Go and frontend) reads the mask first and keeps reading the
  legacy key, so inbounds stored before the upgrade still advertise their range.

On an inbound the old key was always inert — only hysteria's dialer consumed
it — so nothing regresses server-side and no migration is needed. udphop stays
out of the mask dropdown on purpose: it is client-only in core, which refuses
to wrap a server socket, and that form is shared with the inbound editor.
2026-09-09 01:53:00 +02:00

410 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 -buildvcs=true -ldflags "$LDFLAGS" -o xui-release -v .
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.9.9/"
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 comes from the release-page redirect on
# github.com — the host the downloads need anyway — because api.github.com
# has 503'd whole release runs while asset downloads kept working.
case "${{ matrix.platform }}" in
amd64|arm64|armv7|armv6|386)
MTG_MULTI_VER=$(curl -sf $CURL_RETRY -o /dev/null -w '%{redirect_url}' "https://github.com/mhsanaei/mtg-multi/releases/latest" | sed -n 's#.*/releases/tag/##p')
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
sha256sum x-ui-linux-${{ matrix.platform }}.tar.gz > x-ui-linux-${{ matrix.platform }}.tar.gz.sha256
- 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
./x-ui-linux-${{ matrix.platform }}.tar.gz.sha256
- 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*
file_glob: true
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: >-
git
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 -buildvcs=true -ldflags "$LDFLAGS" -o xui-release.exe -v .
- 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.9.9/"
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 comes from the
# release-page redirect on github.com — not api.github.com, whose
# outages have failed release runs while asset downloads kept working.
$MTG_MULTI_VER = (curl.exe -sf --retry 5 --retry-all-errors --retry-delay 3 -o NUL -w '%{redirect_url}' "https://github.com/mhsanaei/mtg-multi/releases/latest") -replace '^.*/releases/tag/', ''
if (-not $MTG_MULTI_VER -or $MTG_MULTI_VER -notmatch '^v[\d.]+$') { 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"
$hash = (Get-FileHash x-ui-windows-amd64.zip -Algorithm SHA256).Hash.ToLower()
[IO.File]::WriteAllText("$PWD\x-ui-windows-amd64.zip.sha256", "$hash x-ui-windows-amd64.zip`n")
- name: Upload files to Artifacts
uses: actions/upload-artifact@v7
with:
name: x-ui-windows-amd64
path: |
./x-ui-windows-amd64.zip
./x-ui-windows-amd64.zip.sha256
- 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*
file_glob: true
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:3}" >&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
# The release exists on every run but the first; edit-first avoids an
# existence probe that can 503 and mis-route into create (422).
if ! retry gh release edit dev-latest --prerelease --latest=false \
--title "Dev build ${short}" --notes "${notes}"; then
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 dev-artifacts/*.sha256 --clobber