From 745811cc6dd330dec2de3ea0e7c21a5c793062b3 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 12:39:07 +0800 Subject: [PATCH] =?UTF-8?q?ci:=20=E5=BB=BA=E7=AB=8B=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E4=B8=8E=20Tag=20=E5=8F=91=E5=B8=83=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增独立仓库级 Gitea Runner,main 和 PR 执行完整质量门禁与不可变 SHA 镜像构建;只有来自 main 历史的语义版本 Tag 才调用受限生产发布助手。专属 Buildx builder 将缓存限制为 2 GB。\n\n验证:流水线结构测试、ShellCheck、Go vet/test、govulncheck、前端 lint/test/build、Compose 渲染及高危依赖审计通过。 --- .gitea/workflows/ci.yml | 101 +++++++++++++++++ deploy/ci/act-runner-config.yaml | 35 ++++++ deploy/ci/easyai-gateway-act-runner.service | 31 ++++++ scripts/ci-build-images.sh | 61 +++++++++++ scripts/provision-ci-runner.sh | 113 ++++++++++++++++++++ tests/ci/pipeline-test.sh | 77 +++++++++++++ 6 files changed, 418 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 deploy/ci/act-runner-config.yaml create mode 100644 deploy/ci/easyai-gateway-act-runner.service create mode 100755 scripts/ci-build-images.sh create mode 100755 scripts/provision-ci-runner.sh create mode 100755 tests/ci/pipeline-test.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..394d0ba --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,101 @@ +name: ci + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + +jobs: + verify: + runs-on: easyai-gateway-linux + env: + IMAGE_TAG: ${{ github.sha }} + steps: + - name: Checkout without external Actions + env: + CI_REPOSITORY: ${{ github.repository }} + CI_SERVER_URL: ${{ github.server_url }} + CI_SHA: ${{ github.sha }} + CI_JOB_TOKEN: ${{ github.token }} + run: | + set -eu + test -n "$CI_JOB_TOKEN" + authorization=$(printf 'x-access-token:%s' "$CI_JOB_TOKEN" | base64 | tr -d '\n') + git init . + git -c "http.extraHeader=AUTHORIZATION: basic $authorization" \ + fetch --no-tags --depth=1 "$CI_SERVER_URL/$CI_REPOSITORY.git" "$CI_SHA" + unset authorization CI_JOB_TOKEN + git checkout --detach FETCH_HEAD + - name: Verify pinned host toolchains + run: | + go version + node --version + pnpm --version + docker version + docker compose version + docker buildx version + shellcheck --version + trivy --version + govulncheck -version + - name: Verify Production deployment prerequisites + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + run: | + grep -Eq '^NoNewPrivs:[[:space:]]+0$' /proc/self/status + test -x /usr/local/sbin/easyai-ai-gateway-release + sudo -n -l /usr/local/sbin/easyai-ai-gateway-release "$IMAGE_TAG" >/dev/null + - name: Verify release tag ancestry + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + env: + CI_REPOSITORY: ${{ github.repository }} + CI_SERVER_URL: ${{ github.server_url }} + CI_SHA: ${{ github.sha }} + CI_JOB_TOKEN: ${{ github.token }} + run: | + set -eu + tag_name=${GITHUB_REF#refs/tags/} + printf '%s' "$tag_name" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$' + authorization=$(printf 'x-access-token:%s' "$CI_JOB_TOKEN" | base64 | tr -d '\n') + git -c "http.extraHeader=AUTHORIZATION: basic $authorization" \ + fetch --no-tags --depth=200 "$CI_SERVER_URL/$CI_REPOSITORY.git" \ + main:refs/remotes/origin/main + unset authorization CI_JOB_TOKEN + test "$(git rev-parse HEAD)" = "$CI_SHA" + git merge-base --is-ancestor "$CI_SHA" refs/remotes/origin/main + - name: Verify Go formatting + run: | + unformatted=$(gofmt -l apps/api) + test -z "$unformatted" || { + printf 'Go files require gofmt:\n%s\n' "$unformatted" >&2 + exit 1 + } + - name: Verify Go code + working-directory: apps/api + run: | + go vet ./... + go test ./... + govulncheck ./... + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm test + - run: pnpm build + - name: Audit JavaScript dependencies + run: pnpm audit --audit-level high + - name: Validate deployment configuration + run: | + docker compose -f docker-compose.yml config --quiet + shellcheck scripts/ci-build-images.sh scripts/provision-ci-runner.sh tests/ci/pipeline-test.sh + ./tests/ci/pipeline-test.sh + - name: Scan repository + run: | + trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL \ + --ignore-unfixed --exit-code 1 --timeout 15m --skip-dirs .git \ + --skip-dirs node_modules . + - run: ./scripts/ci-build-images.sh + - name: Deploy verified release to Production + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + env: + CI_SHA: ${{ github.sha }} + run: | + test "$(git rev-parse HEAD)" = "$CI_SHA" + sudo -n /usr/local/sbin/easyai-ai-gateway-release "$CI_SHA" diff --git a/deploy/ci/act-runner-config.yaml b/deploy/ci/act-runner-config.yaml new file mode 100644 index 0000000..028f7cb --- /dev/null +++ b/deploy/ci/act-runner-config.yaml @@ -0,0 +1,35 @@ +log: + level: info + +runner: + file: /var/lib/easyai-gateway-runner/.runner + capacity: 1 + envs: + GOPROXY: "https://goproxy.cn,direct" + GOSUMDB: "sum.golang.google.cn" + PATH: "/opt/easyai-gateway-ci/bin:/opt/easyai-gateway-ci/toolchains/go/bin:/opt/easyai-gateway-ci/toolchains/node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + AI_GATEWAY_GO_BUILD_IMAGE: "docker.m.daocloud.io/library/golang:1.26.5-alpine" + AI_GATEWAY_API_RUNTIME_IMAGE: "docker.m.daocloud.io/library/alpine:3.22" + AI_GATEWAY_NODE_BUILD_IMAGE: "docker.m.daocloud.io/library/node:24-alpine" + AI_GATEWAY_WEB_RUNTIME_IMAGE: "docker.m.daocloud.io/library/nginx:1.27-alpine" + timeout: 3h + shutdown_timeout: 5m + insecure: false + fetch_timeout: 5s + fetch_interval: 2s + labels: + - "easyai-gateway-linux:host" + +cache: + enabled: true + dir: /var/lib/easyai-gateway-runner/cache + +container: + docker_host: "-" + privileged: false + force_pull: false + force_rebuild: false + valid_volumes: [] + +host: + workdir_parent: /var/lib/easyai-gateway-runner/work diff --git a/deploy/ci/easyai-gateway-act-runner.service b/deploy/ci/easyai-gateway-act-runner.service new file mode 100644 index 0000000..bd207d6 --- /dev/null +++ b/deploy/ci/easyai-gateway-act-runner.service @@ -0,0 +1,31 @@ +[Unit] +Description=EasyAI AI Gateway Gitea Actions runner +After=network-online.target docker.service +Wants=network-online.target docker.service + +[Service] +Type=simple +User=easyai-gateway-runner +Group=easyai-gateway-runner +WorkingDirectory=/var/lib/easyai-gateway-runner +Environment=HOME=/var/lib/easyai-gateway-runner +Environment=PATH=/opt/easyai-gateway-ci/bin:/opt/easyai-gateway-ci/toolchains/go/bin:/opt/easyai-gateway-ci/toolchains/node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +# Gitea 1.22 can leave a long-running poll stale after a completed host job. +# Process exactly one job, then let Restart=always create a fresh poller. +ExecStart=/opt/easyai-gateway-ci/bin/gitea-runner --config /etc/easyai-gateway-runner/config.yaml daemon --once +Restart=always +RestartSec=5s +TimeoutStopSec=330s +# Tag jobs invoke one SHA-validating root helper. Docker access already gives +# this dedicated repository runner an equivalent host-root trust boundary. +NoNewPrivileges=false +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +ReadWritePaths=/var/lib/easyai-gateway-runner + +[Install] +WantedBy=multi-user.target diff --git a/scripts/ci-build-images.sh b/scripts/ci-build-images.sh new file mode 100755 index 0000000..f34ba28 --- /dev/null +++ b/scripts/ci-build-images.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$root" + +tag=${IMAGE_TAG:-} +registry=${AI_GATEWAY_IMAGE_REGISTRY:-registry.cn-shanghai.aliyuncs.com/easyaigc} +builder=${AI_GATEWAY_BUILDX_BUILDER:-easyai-gateway-ci} +cache_limit=${AI_GATEWAY_BUILDX_CACHE_LIMIT:-2gb} +platform=${AI_GATEWAY_PLATFORM:-linux/amd64} + +if [[ ! $tag =~ ^[0-9a-f]{40}$ ]]; then + echo 'IMAGE_TAG must be a full lowercase Git SHA' >&2 + exit 1 +fi +if [[ ! $registry =~ ^[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._/-]+)+$ ]]; then + echo 'AI_GATEWAY_IMAGE_REGISTRY has an invalid format' >&2 + exit 1 +fi + +if ! docker buildx inspect "$builder" >/dev/null 2>&1; then + docker buildx create --name "$builder" --driver docker-container --use >/dev/null +fi +docker buildx inspect "$builder" --bootstrap >/dev/null + +cleanup_cache() { + docker buildx prune --builder "$builder" --force --max-used-space "$cache_limit" >/dev/null || true +} +trap cleanup_cache EXIT HUP INT TERM + +api_image="$registry/ai-gateway:$tag" +web_image="$registry/ai-gateway-web:$tag" +common_args=( + --builder "$builder" + --platform "$platform" + --file Dockerfile + --pull + --load +) + +docker buildx build --builder "$builder" \ + "${common_args[@]:2}" \ + --target api \ + --build-arg "GO_BUILD_IMAGE=${AI_GATEWAY_GO_BUILD_IMAGE:-docker.m.daocloud.io/library/golang:1.26.5-alpine}" \ + --build-arg "API_RUNTIME_IMAGE=${AI_GATEWAY_API_RUNTIME_IMAGE:-docker.m.daocloud.io/library/alpine:3.22}" \ + --tag "$api_image" . + +docker buildx build --builder "$builder" \ + "${common_args[@]:2}" \ + --target web \ + --build-arg "NODE_BUILD_IMAGE=${AI_GATEWAY_NODE_BUILD_IMAGE:-docker.m.daocloud.io/library/node:24-alpine}" \ + --build-arg "WEB_RUNTIME_IMAGE=${AI_GATEWAY_WEB_RUNTIME_IMAGE:-docker.m.daocloud.io/library/nginx:1.27-alpine}" \ + --tag "$web_image" . + +for image in "$api_image" "$web_image"; do + trivy image --scanners vuln,secret --severity HIGH,CRITICAL --ignore-unfixed \ + --exit-code 1 --timeout 15m "$image" +done + +printf 'gateway_images=PASS git_sha=%s api=%s web=%s\n' "$tag" "$api_image" "$web_image" diff --git a/scripts/provision-ci-runner.sh b/scripts/provision-ci-runner.sh new file mode 100755 index 0000000..23a92a0 --- /dev/null +++ b/scripts/provision-ci-runner.sh @@ -0,0 +1,113 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +PREFIX=${CI_RUNNER_PREFIX:-/opt/easyai-gateway-ci} +RUNNER_HOME=${CI_RUNNER_HOME:-/var/lib/easyai-gateway-runner} +RUNNER_USER=${CI_RUNNER_USER:-easyai-gateway-runner} +GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL:-https://git.51easyai.com} +RUNNER_NAME=${RUNNER_NAME:-easyai-gateway-linux} +RUNNER_LABEL=${RUNNER_LABEL:-easyai-gateway-linux:host} + +GITEA_RUNNER_VERSION=2.0.0 +GITEA_RUNNER_SHA256=447156b33407ee045409f5552bd4a188a315cdd4085b4b498d8d4a9ad26c9f73 +GO_VERSION=1.26.5 +GO_SHA256=5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053 +NODE_VERSION=24.16.0 +NODE_SHA256=d804845d34eddc21dc1092b519d643ef40b1f58ec5dec5c22b1f4bd8fabde6c9 +TRIVY_VERSION=0.70.0 +TRIVY_SHA256=8b4376d5d6befe5c24d503f10ff136d9e0c49f9127a4279fd110b727929a5aa9 +PNPM_VERSION=10.18.1 + +[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; } +[ "$(uname -s)-$(uname -m)" = "Linux-x86_64" ] || { echo "only Linux x86_64 is supported" >&2; exit 1; } + +if ! command -v make >/dev/null 2>&1 || ! command -v gcc >/dev/null 2>&1 || \ + ! command -v jq >/dev/null 2>&1 || ! command -v shellcheck >/dev/null 2>&1 || \ + ! docker compose version >/dev/null 2>&1 || ! docker buildx version >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + build-essential ca-certificates curl docker-buildx-plugin docker-compose-plugin \ + git jq perl shellcheck unzip util-linux xz-utils +fi + +download_verified() { + url=$1 + output=$2 + checksum=$3 + if [ ! -f "$output" ] || ! printf '%s %s\n' "$checksum" "$output" | sha256sum -c - >/dev/null 2>&1; then + rm -f "$output" + curl -fL --retry 5 --retry-delay 2 "$url" -o "$output" + fi + printf '%s %s\n' "$checksum" "$output" | sha256sum -c - +} + +if ! id "$RUNNER_USER" >/dev/null 2>&1; then + useradd --system --home-dir "$RUNNER_HOME" --create-home --shell /usr/sbin/nologin "$RUNNER_USER" +fi +usermod -aG docker "$RUNNER_USER" + +install -d -m 0755 "$PREFIX/bin" "$PREFIX/downloads" "$PREFIX/toolchains" +install -d -o "$RUNNER_USER" -g "$RUNNER_USER" -m 0700 \ + "$RUNNER_HOME" "$RUNNER_HOME/cache" "$RUNNER_HOME/work" + +download_verified \ + "https://gitea.com/gitea/runner/releases/download/v${GITEA_RUNNER_VERSION}/gitea-runner-${GITEA_RUNNER_VERSION}-linux-amd64" \ + "$PREFIX/downloads/gitea-runner-${GITEA_RUNNER_VERSION}-linux-amd64" \ + "$GITEA_RUNNER_SHA256" +install -m 0755 "$PREFIX/downloads/gitea-runner-${GITEA_RUNNER_VERSION}-linux-amd64" "$PREFIX/bin/gitea-runner" + +download_verified \ + "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \ + "$PREFIX/downloads/go${GO_VERSION}.linux-amd64.tar.gz" \ + "$GO_SHA256" +rm -rf "$PREFIX/toolchains/go" +tar -xzf "$PREFIX/downloads/go${GO_VERSION}.linux-amd64.tar.gz" -C "$PREFIX/toolchains" + +download_verified \ + "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + "$PREFIX/downloads/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + "$NODE_SHA256" +rm -rf "$PREFIX/toolchains/node" +mkdir -p "$PREFIX/toolchains/node" +tar -xJf "$PREFIX/downloads/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + -C "$PREFIX/toolchains/node" --strip-components=1 +"$PREFIX/toolchains/node/bin/npm" install --global --prefix "$PREFIX/toolchains/node" \ + --ignore-scripts "pnpm@${PNPM_VERSION}" + +download_verified \ + "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \ + "$PREFIX/downloads/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \ + "$TRIVY_SHA256" +tar -xzf "$PREFIX/downloads/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" -C "$PREFIX/bin" trivy +chmod 0755 "$PREFIX/bin/trivy" +GOBIN="$PREFIX/bin" GOPROXY="${GO_MODULE_PROXY:-https://goproxy.cn,direct}" \ + "$PREFIX/toolchains/go/bin/go" install golang.org/x/vuln/cmd/govulncheck@v1.6.0 + +install -d -m 0755 /etc/easyai-gateway-runner +install -m 0644 "$ROOT/deploy/ci/act-runner-config.yaml" /etc/easyai-gateway-runner/config.yaml +install -m 0644 "$ROOT/deploy/ci/easyai-gateway-act-runner.service" /etc/systemd/system/easyai-gateway-act-runner.service + +if [ ! -s "$RUNNER_HOME/.runner" ]; then + [ -n "${RUNNER_REGISTRATION_TOKEN:-}" ] || { + echo "RUNNER_REGISTRATION_TOKEN is required for first registration" >&2 + exit 1 + } + runuser -u "$RUNNER_USER" -- env HOME="$RUNNER_HOME" \ + "$PREFIX/bin/gitea-runner" --config /etc/easyai-gateway-runner/config.yaml \ + register --no-interactive \ + --instance "$GITEA_INSTANCE_URL" \ + --token "$RUNNER_REGISTRATION_TOKEN" \ + --name "$RUNNER_NAME" \ + --labels "$RUNNER_LABEL" + chmod 0600 "$RUNNER_HOME/.runner" +fi + +chown -R "$RUNNER_USER:$RUNNER_USER" "$RUNNER_HOME" +systemctl daemon-reload +systemctl enable --now easyai-gateway-act-runner.service +systemctl --no-pager --full status easyai-gateway-act-runner.service + +env PATH="$PREFIX/bin:$PREFIX/toolchains/go/bin:$PREFIX/toolchains/node/bin:$PATH" \ + sh -c 'gitea-runner --version && go version && node --version && pnpm --version && trivy --version && govulncheck -version' diff --git a/tests/ci/pipeline-test.sh b/tests/ci/pipeline-test.sh new file mode 100755 index 0000000..3abc430 --- /dev/null +++ b/tests/ci/pipeline-test.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # Assertions intentionally match literal workflow variables. +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +workflow=$root/.gitea/workflows/ci.yml +runner_service=$root/deploy/ci/easyai-gateway-act-runner.service +runner_config=$root/deploy/ci/act-runner-config.yaml +provision=$root/scripts/provision-ci-runner.sh +build_images=$root/scripts/ci-build-images.sh + +for file in "$workflow" "$runner_service" "$runner_config" "$provision" "$build_images"; do + [[ -f $file ]] || { + printf 'missing CI/CD file: %s\n' "$file" >&2 + exit 1 + } +done + +grep -q '^name: ci$' "$workflow" +grep -q '^ branches: \[main\]$' "$workflow" +grep -q "^ tags: \['v\*'\]$" "$workflow" +grep -q '^ pull_request:$' "$workflow" +grep -q '^ runs-on: easyai-gateway-linux$' "$workflow" +grep -q '^ - name: Checkout without external Actions$' "$workflow" +grep -q '^ - name: Verify release tag ancestry$' "$workflow" +grep -Fq "if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" "$workflow" +grep -Fq 'git merge-base --is-ancestor "$CI_SHA" refs/remotes/origin/main' "$workflow" +grep -q '^ - run: ./scripts/ci-build-images.sh$' "$workflow" +grep -q '^ - name: Deploy verified release to Production$' "$workflow" +grep -q '^ sudo -n /usr/local/sbin/easyai-ai-gateway-release "$CI_SHA"$' "$workflow" + +for gate in \ + 'gofmt -l' \ + 'go vet ./...' \ + 'go test ./...' \ + 'pnpm install --frozen-lockfile' \ + 'pnpm lint' \ + 'pnpm test' \ + 'pnpm build' \ + 'docker compose -f docker-compose.yml config --quiet' \ + 'govulncheck ./...' \ + 'trivy fs'; do + grep -Fq "$gate" "$workflow" || { + printf 'workflow is missing quality gate: %s\n' "$gate" >&2 + exit 1 + } +done + +grep -q '^User=easyai-gateway-runner$' "$runner_service" +grep -q '^Group=easyai-gateway-runner$' "$runner_service" +grep -q '^NoNewPrivileges=false$' "$runner_service" +grep -q '^ProtectSystem=strict$' "$runner_service" +grep -q '^ProtectHome=true$' "$runner_service" +grep -q '^ReadWritePaths=/var/lib/easyai-gateway-runner$' "$runner_service" +grep -q '^ExecStart=/opt/easyai-gateway-ci/bin/gitea-runner --config /etc/easyai-gateway-runner/config.yaml daemon --once$' "$runner_service" +grep -q 'easyai-gateway-linux:host' "$runner_config" +grep -q '^ capacity: 1$' "$runner_config" + +grep -q '^GITEA_RUNNER_VERSION=2.0.0$' "$provision" +grep -q '^GO_VERSION=1.26.5$' "$provision" +grep -q '^NODE_VERSION=24.16.0$' "$provision" +grep -q '^TRIVY_VERSION=0.70.0$' "$provision" +grep -Fq 'usermod -aG docker "$RUNNER_USER"' "$provision" +grep -Fq 'RUNNER_REGISTRATION_TOKEN is required for first registration' "$provision" + +grep -Fq 'docker buildx build --builder "$builder"' "$build_images" +grep -q -- '--target api' "$build_images" +grep -q -- '--target web' "$build_images" +grep -Fq 'docker buildx prune --builder "$builder" --force --max-used-space "$cache_limit"' "$build_images" +grep -Fq 'trivy image' "$build_images" + +if rg -n '(password|secret|token):[[:space:]]+[^$<{]' "$workflow"; then + echo 'workflow contains a literal credential' >&2 + exit 1 +fi + +echo 'gateway_ci_pipeline_tests=PASS'