feat: перевод CI на Gitea Actions и автообновление по cron
build / build (push) Successful in 18s

Вместо GitHub Actions — .gitea/workflows/build.yml: проверка, тесты, сборка,
валидация настоящими Xray и sing-box, публикация в ветку release и релиз
через Gitea API (tools/gitea-release.sh). Ни gh CLI, ни GitHub-only экшенов.

Для серверов — tools/update-geosite.sh: сверяет sha256 до закачки и выходит
молча, если файл не менялся, проверяет сумму до подмены, подменяет атомарно и
дёргает перезапуск только когда файл реально обновился. Едет в dist/, поэтому
на сервере нужен один базовый URL. В README — cron и systemd timer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-07 14:21:38 +03:00
co-authored by Claude Opus 5
parent f905a76caf
commit c05f6aa0d7
7 changed files with 488 additions and 161 deletions
+13
View File
@@ -443,6 +443,19 @@ def build(out_dir: Path, singbox: str | None, mihomo: str | None) -> int:
+ "\n",
)
# Скрипт обновления едет вместе с артефактами: на сервере тогда нужен
# ровно один базовый URL — и для .dat, и для самого апдейтера.
updater = Path(__file__).with_name("update-geosite.sh")
if updater.is_file():
write(out_dir / updater.name, updater.read_text(encoding="utf-8"))
checksums = "".join(
f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.relative_to(out_dir).as_posix()}\n"
for path in sorted(out_dir.rglob("*"))
if path.is_file()
)
write(out_dir / "SHA256SUMS", checksums)
print(f"geosite.dat: {len(dat)} байт, sha256 {digest[:16]}")
for name in categories:
print(f" {name:<12} {len(resolved[name]):>4} правил")
+48
View File
@@ -0,0 +1,48 @@
#!/bin/sh
# Кладёт sing-box, mihomo и xray в каталог (по умолчанию ./bin).
# Нужен только для сборки .srs/.mrs и для проверки .dat настоящим Xray.
#
# ./tools/fetch-tools.sh [каталог]
set -eu
BIN="${1:-./bin}"
mkdir -p "$BIN"
BIN=$(cd "$BIN" && pwd)
case "$(uname -m)" in
x86_64 | amd64) sb_arch=amd64; mh_arch=amd64; xr_arch=64 ;;
aarch64 | arm64) sb_arch=arm64; mh_arch=arm64; xr_arch=arm64-v8a ;;
*) echo "неподдерживаемая архитектура: $(uname -m)" >&2; exit 1 ;;
esac
# Печатает ссылку на ассет последнего релиза, имя которого совпало с regex.
asset_url() {
curl -fsSL "https://api.github.com/repos/$1/releases/latest" | python3 -c '
import json, re, sys
assets = json.load(sys.stdin)["assets"]
match = next((a for a in assets if re.search(sys.argv[1], a["name"])), None)
if match is None:
sys.exit(f"в релизе нет ассета по шаблону {sys.argv[1]}")
print(match["browser_download_url"])
' "$2"
}
echo "sing-box…"
curl -fsSL "$(asset_url SagerNet/sing-box "-linux-$sb_arch\\.tar\\.gz\$")" |
tar -xz --strip-components=1 -C "$BIN" --wildcards '*/sing-box'
echo "mihomo…"
curl -fsSL "$(asset_url MetaCubeX/mihomo "^mihomo-linux-$mh_arch-v[0-9.]+\\.gz\$")" |
gunzip > "$BIN/mihomo"
echo "xray…"
curl -fsSL -o "$BIN/xray.zip" "$(asset_url XTLS/Xray-core "^Xray-linux-$xr_arch\\.zip\$")"
unzip -oqj "$BIN/xray.zip" xray -d "$BIN"
rm -f "$BIN/xray.zip"
chmod +x "$BIN/sing-box" "$BIN/mihomo" "$BIN/xray"
echo "готово: $BIN"
"$BIN/sing-box" version | head -1
"$BIN/mihomo" -v | head -1
"$BIN/xray" version | head -1
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# Создаёт (или обновляет) релиз в Gitea и заливает в него файлы.
#
# GITEA_URL=https://gitea.example.com GITEA_REPO=owner/repo GITEA_TOKEN=… \
# ./tools/gitea-release.sh <тег> <файл-с-описанием> <файл> [файл …]
#
# Имя ассета — путь относительно dist/ с заменой "/" на "-", чтобы
# sing-box/ipcheck.json и json/ipcheck.json не конфликтовали.
set -eu
[ $# -ge 3 ] || { echo "использование: $0 <тег> <notes.md> <файл...>" >&2; exit 1; }
TAG="$1"; NOTES="$2"; shift 2
: "${GITEA_URL:?не задан GITEA_URL}"
: "${GITEA_REPO:?не задан GITEA_REPO}"
: "${GITEA_TOKEN:?не задан GITEA_TOKEN}"
API="$GITEA_URL/api/v1/repos/$GITEA_REPO/releases"
auth="Authorization: token $GITEA_TOKEN"
json_field() { python3 -c 'import json,sys; print(json.load(sys.stdin).get(sys.argv[1],""))' "$1"; }
payload=$(NOTES_FILE="$NOTES" TAG="$TAG" python3 -c '
import json, os
print(json.dumps({
"tag_name": os.environ["TAG"],
"name": os.environ["TAG"],
"body": open(os.environ["NOTES_FILE"], encoding="utf-8").read(),
"draft": False,
"prerelease": False,
}))')
# Релиз с таким тегом мог остаться от прошлого прогона — тогда переиспользуем его.
id=$(curl -fsS -X POST "$API" -H "$auth" -H 'Content-Type: application/json' \
-d "$payload" 2>/dev/null | json_field id || true)
if [ -z "$id" ]; then
id=$(curl -fsS "$API/tags/$TAG" -H "$auth" | json_field id) ||
{ echo "не удалось ни создать, ни найти релиз $TAG" >&2; exit 1; }
echo "релиз $TAG уже существует (id $id), дозаливаем ассеты"
fi
echo "релиз $TAG: id $id"
for file in "$@"; do
[ -f "$file" ] || { echo "нет файла: $file" >&2; exit 1; }
name=$(printf '%s' "${file#dist/}" | tr '/' '-')
curl -fsS -X POST "$API/$id/assets?name=$name" -H "$auth" \
-F "attachment=@$file" >/dev/null
echo " + $name"
done
+74
View File
@@ -0,0 +1,74 @@
#!/bin/sh
# Обновляет локальный ipcheck.dat из ветки release. Рассчитан на запуск по cron.
#
# Скачивает файл, только если его sha256 отличается от локального, проверяет
# контрольную сумму до подмены и перезапускает сервис только когда файл реально
# изменился. Молчит, если обновлять нечего, — cron не будет слать письма впустую.
#
# ./update-geosite.sh
# BASE_URL=https://gitea.example.com/mrleo1nid/ipcheck-domains/raw/branch/release \
# DEST=/usr/local/x-ui/bin/ipcheck.dat RESTART_CMD='x-ui restart' ./update-geosite.sh
#
# Коды возврата: 0 — обновлено или уже актуально, 1 — ошибка.
set -eu
BASE_URL="${BASE_URL:-https://gitea.example.com/mrleo1nid/ipcheck-domains/raw/branch/release}"
DEST="${DEST:-/usr/local/x-ui/bin/ipcheck.dat}"
NAME="${NAME:-$(basename "$DEST")}"
RESTART_CMD="${RESTART_CMD:-}"
TIMEOUT="${TIMEOUT:-30}"
log() { printf '%s update-geosite: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"; }
die() { log "ОШИБКА: $*" >&2; exit 1; }
command -v curl >/dev/null 2>&1 || die "нужен curl"
if command -v sha256sum >/dev/null 2>&1; then
hash_of() { sha256sum "$1" | cut -d' ' -f1; }
elif command -v shasum >/dev/null 2>&1; then
hash_of() { shasum -a 256 "$1" | cut -d' ' -f1; }
else
die "нужен sha256sum или shasum"
fi
fetch() { curl -fsSL --retry 3 --retry-delay 2 --max-time "$TIMEOUT" "$1"; }
# Без промежуточной переменной статус вернул бы cut, а не curl.
remote_line=$(fetch "$BASE_URL/$NAME.sha256sum") ||
die "не удалось скачать $BASE_URL/$NAME.sha256sum"
remote_sum=$(printf '%s' "$remote_line" | cut -d' ' -f1)
case "$remote_sum" in
[0-9a-f][0-9a-f]*) [ "${#remote_sum}" -eq 64 ] || die "битая контрольная сумма: $remote_sum" ;;
*) die "битая контрольная сумма: $remote_sum" ;;
esac
if [ -f "$DEST" ] && [ "$(hash_of "$DEST")" = "$remote_sum" ]; then
exit 0 # уже актуально — тишина
fi
tmp=$(mktemp "${TMPDIR:-/tmp}/ipcheck.XXXXXX") || die "не удалось создать временный файл"
trap 'rm -f "$tmp"' EXIT INT TERM
fetch "$BASE_URL/$NAME" > "$tmp" || die "не удалось скачать $BASE_URL/$NAME"
got=$(hash_of "$tmp")
[ "$got" = "$remote_sum" ] || die "контрольная сумма не совпала: ожидалось $remote_sum, получено $got"
[ -s "$tmp" ] || die "скачан пустой файл"
mkdir -p "$(dirname "$DEST")"
if [ -f "$DEST" ]; then
cp -p "$DEST" "$DEST.bak" || die "не удалось сохранить резервную копию"
fi
# mv в пределах одного каталога атомарен: Xray никогда не увидит половину файла.
cat "$tmp" > "$DEST.new" && mv -f "$DEST.new" "$DEST" || die "не удалось записать $DEST"
log "обновлён $DEST (sha256 $remote_sum)"
if [ -n "$RESTART_CMD" ]; then
if sh -c "$RESTART_CMD"; then
log "выполнено: $RESTART_CMD"
else
die "перезапуск не удался: $RESTART_CMD"
fi
fi