ci: 建立生产镜像与 Tag 发布流水线

新增独立仓库级 Gitea Runner,main 和 PR 执行完整质量门禁与不可变 SHA 镜像构建;只有来自 main 历史的语义版本 Tag 才调用受限生产发布助手。专属 Buildx builder 将缓存限制为 2 GB。\n\n验证:流水线结构测试、ShellCheck、Go vet/test、govulncheck、前端 lint/test/build、Compose 渲染及高危依赖审计通过。
This commit is contained in:
chengcheng 2026-07-17 12:39:07 +08:00
parent af13a0444e
commit 745811cc6d
6 changed files with 418 additions and 0 deletions

101
.gitea/workflows/ci.yml Normal file
View File

@ -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"

View File

@ -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

View File

@ -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

61
scripts/ci-build-images.sh Executable file
View File

@ -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"

113
scripts/provision-ci-runner.sh Executable file
View File

@ -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'

77
tests/ci/pipeline-test.sh Executable file
View File

@ -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'