name: Build and Deploy Gateway on: push: branches: [master] workflow_dispatch: inputs: hard_deploy: description: Always rebuild and deploy both Gateway images required: false default: false type: boolean env: DEPLOY_PATH: /opt/vpn-proxy BASE_IMAGE: vpn-proxy-runtime-base:bookworm-slim NODE_BUILD_IMAGE: mirror.gcr.io/library/node:24.21.0-bookworm RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim APT_MIRROR: http://mirror.yandex.ru/debian APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security SINGBOX_VERSION: 1.14.0-rc.5 jobs: build-and-push: runs-on: ubuntu-22.04 outputs: affected_components: ${{ steps['gateway-build'].outputs.affected_components }} restart_scope: ${{ steps['gateway-build'].outputs.restart_scope }} steps: - name: Clone repository env: GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') rm -rf repo git clone "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo cd repo git checkout ${{ gitea.sha }} - name: Build and push gateway image id: gateway-build env: HARD_DEPLOY_INPUT: ${{ inputs.hard_deploy }} run: | set -euo pipefail cd repo REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway" CONTROL_IMAGE="${IMAGE}-control" DATAPLANE_IMAGE="${IMAGE}-dataplane" EVENT_NAME="${{ gitea.event_name }}" case "${EVENT_NAME}:${HARD_DEPLOY_INPUT}" in workflow_dispatch:true) HARD_DEPLOY=true ;; workflow_dispatch:false|workflow_dispatch:|push:false|push:) HARD_DEPLOY=false ;; *) echo "Invalid hard deploy request: ${EVENT_NAME}:${HARD_DEPLOY_INPUT}" >&2; exit 1 ;; esac BEFORE_SHA="${{ gitea.event.before }}" ZERO_SHA="0000000000000000000000000000000000000000" if [ "$EVENT_NAME" = "push" ] \ && [ -n "$BEFORE_SHA" ] \ && [ "$BEFORE_SHA" != "$ZERO_SHA" ] \ && git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then CHANGED_FILES="$(git diff --no-renames --name-only "$BEFORE_SHA" "${{ gitea.sha }}")" elif [ "$EVENT_NAME" = "push" ]; then CHANGED_FILES="package.json" else CHANGED_FILES="$(git diff-tree --no-renames --no-commit-id --name-only -r -m HEAD)" fi if command -v node >/dev/null 2>&1; then RUNTIME_IMPACT="$(printf '%s\n' "$CHANGED_FILES" | node scripts/runtime-impact.mjs --stdin)" else if ! docker image inspect "${{ env.BASE_IMAGE }}" >/dev/null 2>&1 \ || ! docker run --rm "${{ env.BASE_IMAGE }}" sh -lc 'command -v node >/dev/null'; then echo "Cannot classify runtime impact: Node and the existing runtime base are unavailable." >&2 exit 1 fi RUNTIME_IMPACT="$(printf '%s\n' "$CHANGED_FILES" | docker run --rm -i \ -v "$PWD:/work" \ -w /work \ "${{ env.BASE_IMAGE }}" \ node scripts/runtime-impact.mjs --stdin)" fi AFFECTED_COMPONENTS="$(printf '%s\n' "$RUNTIME_IMPACT" | sed -n 's/^affected-components=//p')" RESTART_SCOPE="$(printf '%s\n' "$RUNTIME_IMPACT" | sed -n 's/^restart-scope=//p')" case "${AFFECTED_COMPONENTS}:${RESTART_SCOPE}" in none:none|control:control|dataplane:both|control+dataplane:both) ;; *) echo "Invalid runtime impact: ${RUNTIME_IMPACT}" >&2; exit 1 ;; esac DOCKER_BUILD_OPTIONS=() if [ "$HARD_DEPLOY" = "true" ]; then echo "Hard deploy requested: forcing no-cache rebuild and deploy of both Gateway images." AFFECTED_COMPONENTS="control+dataplane" RESTART_SCOPE="both" DOCKER_BUILD_OPTIONS=(--no-cache) fi echo "Affected components: ${AFFECTED_COMPONENTS}" echo "Restart scope: ${RESTART_SCOPE}" echo "affected_components=${AFFECTED_COMPONENTS}" >> "$GITHUB_OUTPUT" echo "restart_scope=${RESTART_SCOPE}" >> "$GITHUB_OUTPUT" if command -v npm >/dev/null 2>&1 && node scripts/check-sqlite-runtime.mjs; then npm ci --no-audit --no-fund npm run typecheck npm run check:boundaries npm test npm run build:production else if ! docker run --rm "${{ env.NODE_BUILD_IMAGE }}" sh -lc 'command -v npm >/dev/null && command -v git >/dev/null && test -x /bin/bash'; then echo "Cannot validate change: the pinned Node 24.21.0 build toolchain is unavailable." >&2 exit 1 fi echo "Host npm not found; validating inside ${{ env.NODE_BUILD_IMAGE }}" docker run --rm \ --network host \ -v "$PWD:/work" \ -w /work \ "${{ env.NODE_BUILD_IMAGE }}" \ sh -lc ' npm ci --no-audit --no-fund npm_status=$? if [ "$npm_status" -ne 0 ] || [ ! -x node_modules/.bin/tsc ]; then echo "npm ci failed to install the validation toolchain." >&2 tail -n 200 /root/.npm/_logs/*-debug-0.log >&2 || true exit 1 fi npm run typecheck && npm run check:boundaries && npm test && npm run build:production ' fi if [ "$RESTART_SCOPE" = "none" ]; then echo "Image build and push skipped: no Gateway runtime impact." exit 0 fi echo "Build runner: $(hostname)" echo "Base image: ${{ env.BASE_IMAGE }}" echo "Docker context: $(docker context show 2>/dev/null || true)" docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p' if ! docker image inspect "${{ env.BASE_IMAGE }}" >/dev/null 2>&1 \ || ! docker run --rm "${{ env.BASE_IMAGE }}" sh -lc \ 'command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx "sing-box version ${{ env.SINGBOX_VERSION }}"'; then echo "Runtime base image ${{ env.BASE_IMAGE }} is missing npm or sing-box ${{ env.SINGBOX_VERSION }}; building it now." BASE_IMAGE="${{ env.RUNTIME_BASE_SOURCE_IMAGE }}" \ RUNTIME_BASE_IMAGE="${{ env.BASE_IMAGE }}" \ APT_MIRROR="${{ env.APT_MIRROR }}" \ APT_SECURITY_MIRROR="${{ env.APT_SECURITY_MIRROR }}" \ SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \ NODE_BUILD_IMAGE="${{ env.NODE_BUILD_IMAGE }}" \ ./scripts/build-runtime-base.sh fi echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin DOCKER_BUILDKIT=1 docker build \ "${DOCKER_BUILD_OPTIONS[@]}" \ --network host \ --pull=false \ --build-arg NODE_BUILD_IMAGE="${{ env.NODE_BUILD_IMAGE }}" \ --build-arg BASE_IMAGE="${{ env.BASE_IMAGE }}" \ --build-arg SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \ --build-arg INSTALL_RUNTIME_DEPS=false \ --build-arg INSTALL_SINGBOX=false \ -t "${CONTROL_IMAGE}:latest" \ -t "${CONTROL_IMAGE}:${{ gitea.sha }}" \ -t "${DATAPLANE_IMAGE}:latest" \ -t "${DATAPLANE_IMAGE}:${{ gitea.sha }}" \ . docker run --rm --entrypoint sing-box "${CONTROL_IMAGE}:${{ gitea.sha }}" version 2>&1 \ | grep -Fx "sing-box version ${{ env.SINGBOX_VERSION }}" docker run --rm --entrypoint sing-box "${DATAPLANE_IMAGE}:${{ gitea.sha }}" version 2>&1 \ | grep -Fx "sing-box version ${{ env.SINGBOX_VERSION }}" docker push "${CONTROL_IMAGE}:latest" docker push "${CONTROL_IMAGE}:${{ gitea.sha }}" docker push "${DATAPLANE_IMAGE}:latest" docker push "${DATAPLANE_IMAGE}:${{ gitea.sha }}" deploy: runs-on: lxc-111 needs: build-and-push steps: - name: Clone repository env: GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') rm -rf repo git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo cd repo git checkout ${{ gitea.sha }} - name: Pull and deploy gateway image run: | set -euo pipefail cd repo REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway" CONTROL_IMAGE="${IMAGE}-control:${{ gitea.sha }}" DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}" AFFECTED_COMPONENTS="${{ needs['build-and-push'].outputs.affected_components }}" RESTART_SCOPE="${{ needs['build-and-push'].outputs.restart_scope }}" case "${AFFECTED_COMPONENTS}:${RESTART_SCOPE}" in none:none|control:control|dataplane:both|control+dataplane:both) ;; *) echo "Invalid runtime impact output: ${AFFECTED_COMPONENTS}:${RESTART_SCOPE}" >&2; exit 1 ;; esac if [ "$RESTART_SCOPE" = "none" ]; then echo "Deploy skipped: no Gateway runtime impact." exit 0 fi UPDATE_DATAPLANE=false if [ "$RESTART_SCOPE" = "both" ]; then UPDATE_DATAPLANE=true fi echo "Deploy runner: $(hostname)" echo "Affected components: ${AFFECTED_COMPONENTS}" echo "Restart scope: ${RESTART_SCOPE}" echo "Update dataplane: ${UPDATE_DATAPLANE}" echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin DEPLOY_PATH="${{ env.DEPLOY_PATH }}" \ CONTROL_IMAGE="${CONTROL_IMAGE}" \ DATAPLANE_IMAGE="${DATAPLANE_IMAGE}" \ UPDATE_DATAPLANE="${UPDATE_DATAPLANE}" \ bash scripts/deploy-gateway.sh VERSION_JSON="$(curl --noproxy '*' -fsS http://127.0.0.1:3456/api/version)" printf '%s\n' "$VERSION_JSON" printf '%s\n' "$VERSION_JSON" \ | grep -F "\"singBox\":\"${{ env.SINGBOX_VERSION }}\""