refactor(release): 改为 Agent 双阶段人工发布

删除 Gitea Actions、Tag/Main 自动流水线和旧 Runner 配置,取消 Git 操作与发布授权的绑定。\n\n新增本地镜像发布、固定生产部署助手、digest manifest、迁移安全检查、simulation 冒烟及显式回滚流程。\n\n验证:pnpm lint、pnpm test、pnpm build、Go 全量测试、ShellCheck、Compose 配置、人工发布测试和 linux/amd64 完整栈冒烟。
This commit is contained in:
2026-07-22 15:13:40 +08:00
parent cbebfd7baa
commit dae5d16a58
32 changed files with 1820 additions and 1990 deletions
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
function fail(message) {
console.error(`api_release_smoke=FAIL ${message}`)
process.exit(1)
}
function assert(condition, message) {
if (!condition) fail(message)
}
const baseURL = (process.env.RELEASE_SMOKE_BASE_URL || '').replace(/\/+$/, '')
const webURL = (process.env.RELEASE_SMOKE_WEB_URL || '').replace(/\/+$/, '')
const composeProject = process.env.RELEASE_SMOKE_COMPOSE_PROJECT || ''
const composeFile = process.env.RELEASE_SMOKE_COMPOSE_FILE || 'docker-compose.yml'
const nonce = process.env.RELEASE_SMOKE_NONCE || Date.now().toString(36)
const databaseUser = process.env.RELEASE_SMOKE_DATABASE_USER || 'easyai'
const databaseName = process.env.RELEASE_SMOKE_DATABASE_NAME || 'easyai_ai_gateway'
if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(baseURL)) fail('invalid RELEASE_SMOKE_BASE_URL')
if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(webURL)) fail('invalid RELEASE_SMOKE_WEB_URL')
if (!/^[a-z0-9][a-z0-9_-]{2,62}$/.test(composeProject)) fail('invalid RELEASE_SMOKE_COMPOSE_PROJECT')
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(databaseUser)) fail('invalid RELEASE_SMOKE_DATABASE_USER')
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(databaseName)) fail('invalid RELEASE_SMOKE_DATABASE_NAME')
async function request(path, { method = 'GET', token = '', body } = {}) {
const response = await fetch(`${baseURL}${path}`, {
method,
signal: AbortSignal.timeout(15_000),
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
const text = await response.text()
let payload
try {
payload = text ? JSON.parse(text) : null
} catch {
fail(`${method} ${path} returned non-JSON HTTP ${response.status}`)
}
if (!response.ok) fail(`${method} ${path} returned HTTP ${response.status}`)
return { response, payload }
}
async function waitForAPI() {
let lastError = 'API did not respond'
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
const response = await fetch(`${baseURL}/api/v1/readyz`, {
signal: AbortSignal.timeout(2_000),
})
if (response.ok) return
lastError = `readiness returned HTTP ${response.status}`
} catch (error) {
lastError = error.message
}
await new Promise((resolve) => setTimeout(resolve, 1_000))
}
fail(`API readiness timed out: ${lastError}`)
}
await waitForAPI()
const health = await request('/api/v1/healthz')
assert(health.payload?.ok === true && health.payload?.service === 'easyai-ai-gateway', 'health payload mismatch')
const ready = await request('/api/v1/readyz')
assert(ready.payload?.ok === true, 'readiness payload mismatch')
const openapi = await request('/api/v1/openapi.json')
for (const path of [
'/api/v1/healthz',
'/api/v1/readyz',
'/api/v1/models',
'/api/v1/chat/completions',
'/api/v1/responses',
'/api/v1/images/generations',
]) {
assert(openapi.payload?.paths?.[path], `OpenAPI is missing ${path}`)
}
const username = `release_smoke_${nonce}`.slice(0, 60)
const password = `Release-Smoke-${nonce}-9x!`
const registration = await request('/api/v1/auth/register', {
method: 'POST',
body: { username, email: `${username}@example.invalid`, password },
})
assert(typeof registration.payload?.accessToken === 'string', 'registration did not return an access token')
const login = await request('/api/v1/auth/login', {
method: 'POST',
body: { account: username, password },
})
const jwt = login.payload?.accessToken
assert(typeof jwt === 'string' && jwt.length > 20, 'login did not return an access token')
try {
execFileSync('docker', [
'compose', '-p', composeProject, '-f', composeFile,
'exec', '-T', 'postgres', 'psql', '-v', 'ON_ERROR_STOP=1',
'-U', databaseUser, '-d', databaseName, '-c',
`UPDATE gateway_wallet_accounts SET balance = 100000 WHERE gateway_user_id = (SELECT id FROM gateway_users WHERE username = '${username.replaceAll("'", "''")}')`,
], { stdio: ['ignore', 'ignore', 'pipe'] })
} catch {
fail('could not fund the ephemeral smoke wallet')
}
const apiKeyResult = await request('/api/v1/api-keys', {
method: 'POST', token: jwt, body: { name: 'release smoke key' },
})
const apiKey = apiKeyResult.payload?.secret
assert(typeof apiKey === 'string' && apiKey.startsWith('sk-gw-'), 'API key creation failed')
const models = await request('/api/v1/models', { token: apiKey })
assert(Array.isArray(models.payload?.items) || Array.isArray(models.payload?.data), 'models response has no items')
const simulation = { runMode: 'simulation', simulation: true, simulationDurationMs: 5 }
const chat = await request('/api/v1/chat/completions', {
method: 'POST', token: apiKey,
body: { model: 'openai:gpt-4o-mini', messages: [{ role: 'user', content: 'release smoke' }], ...simulation },
})
assert(/^chatcmpl-/.test(chat.payload?.id || ''), 'chat simulation response mismatch')
const responses = await request('/api/v1/responses', {
method: 'POST', token: apiKey,
body: { model: 'openai:gpt-4o-mini', input: 'release smoke', ...simulation },
})
assert(/^resp/.test(responses.payload?.id || ''), 'responses simulation response mismatch')
const images = await request('/api/v1/images/generations', {
method: 'POST', token: apiKey,
body: { model: 'openai:gpt-image-1', prompt: 'release smoke', ...simulation },
})
assert(Array.isArray(images.payload?.data) && images.payload.data.length > 0, 'image simulation response mismatch')
const webHealth = await fetch(`${webURL}/api/v1/healthz`, { signal: AbortSignal.timeout(15_000) })
assert(webHealth.ok, `web API reverse proxy returned HTTP ${webHealth.status}`)
const webApp = await fetch(`${webURL}/`, { signal: AbortSignal.timeout(15_000) })
assert(webApp.ok && (await webApp.text()).includes('EasyAI AI Gateway'), 'web application smoke failed')
console.log('api_release_smoke=PASS health=ok readiness=ok openapi=ok auth=ok chat=ok responses=ok images=ok web=ok')
-101
View File
@@ -1,101 +0,0 @@
#!/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}
keep_images=0
build_complete=0
readonly trivy_db_repository=ghcr.m.daocloud.io/aquasecurity/trivy-db:2
case ${1:-} in
'') ;;
--keep-images) keep_images=1 ;;
*)
echo 'usage: ci-build-images.sh [--keep-images]' >&2
exit 64
;;
esac
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 [[ ! $cache_limit =~ ^[1-9][0-9]*(kb|mb|gb)$ ]]; then
echo 'AI_GATEWAY_BUILDX_CACHE_LIMIT has an invalid format' >&2
exit 1
fi
api_image="$registry/ai-gateway:$tag"
web_image="$registry/ai-gateway-web:$tag"
cleanup() {
original_status=$?
trap - EXIT HUP INT TERM
cleanup_status=0
docker buildx prune --builder "$builder" --force --max-used-space "$cache_limit" >/dev/null || {
cleanup_status=$?
echo 'failed to prune the CI BuildKit cache' >&2
}
if [[ $keep_images -ne 1 || $build_complete -ne 1 ]]; then
docker image rm "$api_image" "$web_image" >/dev/null 2>&1 || {
image_rm_status=$?
[[ $cleanup_status -ne 0 ]] || cleanup_status=$image_rm_status
echo 'failed to remove CI image tags' >&2
}
fi
if [[ $original_status -ne 0 ]]; then
exit "$original_status"
fi
exit "$cleanup_status"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
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
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 --db-repository "$trivy_db_repository" \
--scanners vuln,secret --severity HIGH,CRITICAL --ignore-unfixed \
--exit-code 1 --timeout 15m "$image"
done
build_complete=1
printf 'gateway_images=PASS git_sha=%s api=%s web=%s\n' "$tag" "$api_image" "$web_image"
+7 -119
View File
@@ -5,10 +5,7 @@ import { lstatSync, readFileSync } from 'node:fs'
import { basename } from 'node:path'
const migrationDirectory = 'apps/api/migrations/'
const baselineFile = 'deploy/ci/production-migration-base'
const base = process.argv[2] || process.env.PRODUCTION_MIGRATION_BASE_SHA || ''
const immutableRef =
process.argv[3] || process.env.MIGRATION_IMMUTABILITY_REF || ''
const base = process.argv[2] || process.env.RELEASE_BASE_SHA || ''
function fail(message) {
console.error(`migration_safety=FAIL ${message}`)
@@ -28,7 +25,7 @@ function git(args, options = {}) {
}
if (!/^[0-9a-f]{40}$/.test(base)) {
fail('PRODUCTION_MIGRATION_BASE_SHA must be a full lowercase commit SHA')
fail('RELEASE_BASE_SHA must be a full lowercase commit SHA')
}
const root = git(['rev-parse', '--show-toplevel']).trim()
@@ -41,34 +38,14 @@ const resolvedBase = git([
`${base}^{commit}`,
]).trim()
if (resolvedBase !== base) {
fail('production migration baseline did not resolve exactly')
fail('release base did not resolve exactly')
}
const ancestry = spawnSync('git', ['merge-base', '--is-ancestor', base, 'HEAD'], {
stdio: 'ignore',
})
if (ancestry.status !== 0) {
fail('production migration baseline is not an ancestor of HEAD')
}
const head = git(['rev-parse', '--verify', 'HEAD^{commit}']).trim()
const baselineTreeEntry = git(['ls-tree', 'HEAD', '--', baselineFile]).trim()
let baselineState
try {
baselineState = lstatSync(baselineFile)
} catch {
fail('production migration baseline file is unavailable in the worktree')
}
if (
!/^100644 blob [0-9a-f]+\t/.test(baselineTreeEntry) ||
!baselineState.isFile() ||
baselineState.isSymbolicLink()
) {
fail('production migration baseline must be a mode 100644 regular file')
}
if (readFileSync(baselineFile, 'utf8').trim() !== base) {
fail('production migration baseline argument does not match the reviewed file')
fail('release base is not an ancestor of HEAD')
}
function changedMigrations(from) {
@@ -252,94 +229,8 @@ const destructiveRules = [
const violations = []
let addedFiles = 0
let immutableBase = ''
const orderingCandidates = new Set()
if (immutableRef) {
if (
!/^[0-9a-f]{40}$/.test(immutableRef) &&
immutableRef !== 'refs/remotes/origin/main'
) {
fail('MIGRATION_IMMUTABILITY_REF must be a full commit SHA or refs/remotes/origin/main')
}
const immutableTip = git([
'rev-parse',
'--verify',
'--end-of-options',
`${immutableRef}^{commit}`,
]).trim()
const baseAtEvent = spawnSync(
'git',
['show', `${immutableTip}:${baselineFile}`],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
)
if (baseAtEvent.status === 0) {
const previousBaselineTreeEntry = git([
'ls-tree',
immutableTip,
'--',
baselineFile,
]).trim()
if (!/^100644 blob [0-9a-f]+\t/.test(previousBaselineTreeEntry)) {
fail('previous production migration baseline must be a mode 100644 regular file')
}
const previousProductionBase = baseAtEvent.stdout.trim()
if (!/^[0-9a-f]{40}$/.test(previousProductionBase)) {
fail('previous production migration baseline is invalid')
}
const baselineAdvance = spawnSync(
'git',
['merge-base', '--is-ancestor', previousProductionBase, base],
{ stdio: 'ignore' },
)
if (baselineAdvance.status !== 0) {
fail('production migration baseline must only move forward')
}
}
const baselineWasAlreadyReviewed = spawnSync(
'git',
['merge-base', '--is-ancestor', base, immutableTip],
{ stdio: 'ignore' },
)
if (baselineWasAlreadyReviewed.status !== 0) {
fail('production migration baseline must belong to the immutable event base history')
}
if (immutableTip === head) {
const parent = spawnSync(
'git',
['rev-parse', '--verify', `${immutableTip}^1^{commit}`],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
)
immutableBase = parent.status === 0 ? parent.stdout.trim() : ''
} else {
const immutableAncestry = spawnSync(
'git',
['merge-base', '--is-ancestor', immutableTip, head],
{ stdio: 'ignore' },
)
if (immutableAncestry.status !== 0) {
fail('migration immutability base is not an ancestor of HEAD')
}
immutableBase = immutableTip
}
if (immutableBase) {
const immutableChanges = changedMigrations(immutableBase)
for (let index = 0; index < immutableChanges.length; index += 2) {
const status = immutableChanges[index]
const file = immutableChanges[index + 1]
if (status === 'A') {
orderingCandidates.add(file)
} else {
violations.push(
`${file}: migration present on main is immutable (status ${status})`,
)
}
}
}
}
function migrationNamesAt(ref) {
const output = git(
['ls-tree', '-r', '-z', '--name-only', ref, '--', migrationDirectory],
@@ -358,14 +249,11 @@ function migrationNamesAt(ref) {
.sort()
}
const orderingBase = immutableBase || base
const existingNames = migrationNamesAt(orderingBase)
const existingNames = migrationNamesAt(base)
const previousMaximumName = existingNames.at(-1) || ''
if (!immutableBase) {
for (let index = 0; index < fields.length; index += 2) {
if (fields[index] === 'A') orderingCandidates.add(fields[index + 1])
}
for (let index = 0; index < fields.length; index += 2) {
if (fields[index] === 'A') orderingCandidates.add(fields[index + 1])
}
for (let index = 0; index < fields.length; index += 2) {
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
tag=${1:-}
die() {
printf 'invalid release tag: %s\n' "$tag" >&2
exit 1
}
[[ $tag == v* ]] || die
version=${tag#v}
# Bash arrays discard trailing empty fields, so parsing first would accept
# values such as v1.2.3. and v1.2.3-alpha. Validate the complete string with
# the SemVer 2.0.0 grammar before doing anything else with the tag.
semver_pattern='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
LC_ALL=C
export LC_ALL
[[ $version =~ $semver_pattern ]] || die
printf 'release_tag=PASS tag=%s\n' "$tag"
+5 -99
View File
@@ -6,7 +6,7 @@ COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-easyai-ai-gateway}"
AI_GATEWAY_PLATFORM="${AI_GATEWAY_PLATFORM:-linux/amd64}"
AI_GATEWAY_IMAGE_REGISTRY="${AI_GATEWAY_IMAGE_REGISTRY:-registry.cn-shanghai.aliyuncs.com/easyaigc}"
AI_GATEWAY_IMAGE_TAG="${AI_GATEWAY_IMAGE_TAG:-latest}"
AI_GATEWAY_IMAGE_TAG="${AI_GATEWAY_IMAGE_TAG:-local}"
AI_GATEWAY_API_IMAGE="${AI_GATEWAY_API_IMAGE:-${AI_GATEWAY_IMAGE_REGISTRY}/ai-gateway:${AI_GATEWAY_IMAGE_TAG}}"
AI_GATEWAY_WEB_IMAGE="${AI_GATEWAY_WEB_IMAGE:-${AI_GATEWAY_IMAGE_REGISTRY}/ai-gateway-web:${AI_GATEWAY_IMAGE_TAG}}"
ACTION="${1:-deploy}"
@@ -22,11 +22,8 @@ compose=(docker compose -f "$COMPOSE_FILE")
usage() {
cat <<'EOF'
Usage:
scripts/deploy-compose.sh Build, migrate, start, and verify
scripts/deploy-compose.sh Build, migrate, start, and verify locally
scripts/deploy-compose.sh deploy Same as default
scripts/deploy-compose.sh push Build and push API/Web images
scripts/deploy-compose.sh push-only
Push already-built API/Web images
scripts/deploy-compose.sh down Stop containers, keep volumes
scripts/deploy-compose.sh clean Stop containers and remove volumes
@@ -38,8 +35,10 @@ Useful environment overrides:
AI_GATEWAY_WEB_PORT=5178
AI_GATEWAY_API_PORT=8088
AI_GATEWAY_DB_PORT=54329
AI_GATEWAY_PUSH=1
AI_GATEWAY_SKIP_BUILD=1
Production publishing is intentionally unavailable here. Use:
scripts/publish-release-images.sh --components auto
EOF
}
@@ -125,86 +124,6 @@ build_images() {
fi
}
package_version() {
local version=""
version="$(
sed -nE 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$PROJECT_ROOT/package.json" \
| head -n 1
)"
if [[ -z "$version" ]]; then
echo "[ai-gateway] cannot infer package version from package.json; set AI_GATEWAY_IMAGE_TAG" >&2
exit 1
fi
echo "$version"
}
push_version_tag() {
if [[ "$AI_GATEWAY_IMAGE_TAG" == "latest" ]]; then
package_version
else
echo "$AI_GATEWAY_IMAGE_TAG"
fi
}
image_repository() {
local image="$1"
echo "${image%:*}"
}
tag_release_images() {
local version_tag="$1"
local api_repo web_repo
local api_version_image api_latest_image web_version_image web_latest_image
api_repo="$(image_repository "$AI_GATEWAY_API_IMAGE")"
web_repo="$(image_repository "$AI_GATEWAY_WEB_IMAGE")"
api_version_image="${api_repo}:${version_tag}"
api_latest_image="${api_repo}:latest"
web_version_image="${web_repo}:${version_tag}"
web_latest_image="${web_repo}:latest"
docker image inspect "$AI_GATEWAY_API_IMAGE" >/dev/null 2>&1 || {
echo "[ai-gateway] missing local API image: ${AI_GATEWAY_API_IMAGE}; build it first" >&2
exit 1
}
docker image inspect "$AI_GATEWAY_WEB_IMAGE" >/dev/null 2>&1 || {
echo "[ai-gateway] missing local Web image: ${AI_GATEWAY_WEB_IMAGE}; build it first" >&2
exit 1
}
docker tag "$AI_GATEWAY_API_IMAGE" "$api_version_image"
docker tag "$AI_GATEWAY_API_IMAGE" "$api_latest_image"
docker tag "$AI_GATEWAY_WEB_IMAGE" "$web_version_image"
docker tag "$AI_GATEWAY_WEB_IMAGE" "$web_latest_image"
RELEASE_IMAGES=(
"$api_version_image"
"$api_latest_image"
"$web_version_image"
"$web_latest_image"
)
}
push_images() {
local version_tag
local image
version_tag="$(push_version_tag)"
echo "[ai-gateway] release image tag: ${version_tag}"
echo "[ai-gateway] latest tag will also be pushed"
tag_release_images "$version_tag"
for image in "${RELEASE_IMAGES[@]}"; do
echo "[ai-gateway] pushing image: ${image}"
if ! docker push "$image"; then
echo "[ai-gateway] failed to push image; login may be required:" >&2
echo "[ai-gateway] docker login --username=<your-aliyun-account> registry.cn-shanghai.aliyuncs.com" >&2
exit 1
fi
done
}
deploy() {
cd "$PROJECT_ROOT"
@@ -227,10 +146,6 @@ deploy() {
build_images
if [[ "${AI_GATEWAY_PUSH:-0}" == "1" ]]; then
push_images
fi
echo "[ai-gateway] starting postgres"
"${compose[@]}" up -d postgres
wait_for_service_healthy postgres
@@ -262,15 +177,6 @@ case "$ACTION" in
deploy|up)
deploy
;;
push)
cd "$PROJECT_ROOT"
build_images
push_images
;;
push-only)
cd "$PROJECT_ROOT"
push_images
;;
down)
cd "$PROJECT_ROOT"
"${compose[@]}" down --remove-orphans
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
production_host=${AI_GATEWAY_PRODUCTION_HOST:-root@110.42.51.33}
remote_helper=${AI_GATEWAY_REMOTE_RELEASE_HELPER:-/usr/local/sbin/easyai-ai-gateway-release}
action=deploy
value=
usage() {
cat <<'EOF'
Usage:
scripts/deploy-production-release.sh dist/releases/<git-sha>.json
scripts/deploy-production-release.sh --rollback <historical-git-sha>
scripts/deploy-production-release.sh --status
This command changes production. Run it only after a separate successful
publish command and explicit user confirmation.
EOF
}
case ${1:-} in
--status)
action=status
[[ $# -eq 1 ]] || { usage >&2; exit 64; }
;;
--rollback)
action=rollback
[[ $# -eq 2 ]] || { usage >&2; exit 64; }
value=$2
;;
-h|--help|'')
usage
[[ ${1:-} == -h || ${1:-} == --help ]] && exit 0
exit 64
;;
*)
[[ $# -eq 1 ]] || { usage >&2; exit 64; }
value=$1
;;
esac
[[ $production_host =~ ^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$ ]] || {
echo 'AI_GATEWAY_PRODUCTION_HOST must use user@host syntax' >&2
exit 1
}
[[ $remote_helper =~ ^/[A-Za-z0-9._/-]+$ ]] || {
echo 'AI_GATEWAY_REMOTE_RELEASE_HELPER must be an absolute safe path' >&2
exit 1
}
if [[ $action == status ]]; then
exec ssh -o BatchMode=yes "$production_host" "$remote_helper status"
fi
if [[ $action == rollback ]]; then
[[ $value =~ ^[0-9a-f]{40}$ ]] || {
echo 'rollback release must be a full lowercase Git SHA' >&2
exit 1
}
ssh -o BatchMode=yes "$production_host" "$remote_helper rollback $value"
echo "production_rollback=PASS release=$value"
exit 0
fi
manifest=$(cd "$(dirname "$value")" 2>/dev/null && pwd)/$(basename "$value")
[[ -f $manifest && ! -L $manifest ]] || {
echo 'release manifest must be a regular file' >&2
exit 1
}
node "$root/scripts/release-manifest.mjs" validate "$manifest"
source_sha=$(node "$root/scripts/release-manifest.mjs" get "$manifest" sourceSha)
base_sha=$(node "$root/scripts/release-manifest.mjs" get "$manifest" baseReleaseSha)
remote_manifest=/tmp/easyai-ai-gateway-release-$source_sha.json
status_file=$(mktemp)
cleanup() {
rm -f "$status_file"
ssh -o BatchMode=yes "$production_host" "rm -f $remote_manifest" >/dev/null 2>&1 || true
}
trap cleanup EXIT HUP INT TERM
if ssh -o BatchMode=yes "$production_host" "$remote_helper status" >"$status_file" 2>/dev/null; then
node "$root/scripts/release-manifest.mjs" validate "$status_file" >/dev/null
current_sha=$(node "$root/scripts/release-manifest.mjs" get "$status_file" sourceSha)
[[ $current_sha == "$base_sha" ]] || {
printf 'production base changed after publish: current=%s manifest_base=%s\n' \
"$current_sha" "$base_sha" >&2
exit 1
}
else
[[ -z $base_sha ]] || {
echo 'production release status is unavailable for a non-bootstrap manifest' >&2
exit 1
}
fi
scp -q "$manifest" "$production_host:$remote_manifest"
ssh -o BatchMode=yes "$production_host" "$remote_helper deploy $remote_manifest"
echo "production_deploy=PASS release=$source_sha"
-402
View File
@@ -1,402 +0,0 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
PREFIX=/opt/easyai-gateway-ci
GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL:-https://git.51easyai.com}
RUNNER_NAME=${RUNNER_NAME:-easyai-gateway-ci-unprivileged-v2}
RUNNER_VOLUME=easyai-gateway-ci-v2-data
RUNNER_CONTAINER=easyai-gateway-ci-v2-runner
LEGACY_BUILDER=${AI_GATEWAY_BUILDX_BUILDER:-easyai-gateway-ci}
MIN_FREE_GIB=${CI_RUNNER_MIN_FREE_GIB:-8}
MIN_POST_INSTALL_FREE_GIB=${CI_RUNNER_MIN_POST_INSTALL_FREE_GIB:-4}
RUNNER_CPUS=2
RUNNER_MEMORY=2g
RUNNER_MEMORY_SWAP=3g
RUNNER_PIDS_LIMIT=1024
GITEA_RUNNER_VERSION=2.0.0
GO_VERSION=1.26.5
GO_SHA256=5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053
NODE_VERSION=24.16.0
NODE_SHA256=d804845d34eddc21dc1092b519d643ef40b1f58ec5dec5c22b1f4bd8fabde6c9
TRIVY_VERSION=0.70.0
TRIVY_SHA256=8b4376d5d6befe5c24d503f10ff136d9e0c49f9127a4279fd110b727929a5aa9
SHELLCHECK_VERSION=0.11.0
SHELLCHECK_SHA256=8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198
COMPOSE_VERSION=5.3.1
COMPOSE_SHA256=f9ebc6ebdb19d769b793c245a736caaeb198c62587f13b25c660c13b4987f959
PNPM_VERSION=10.18.1
RUNNER_IMAGE='docker.io/gitea/runner:2.0.0-dind-rootless@sha256:5b7b625ff773d0ee761788c47582503ec1b241fa5b81edebad48a57e663f4f3a'
JOB_IMAGE='docker.io/library/node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7'
RUNNER_IMAGE_SOURCE=${CI_RUNNER_IMAGE_SOURCE:-$RUNNER_IMAGE}
NESTED_DOCKER_REGISTRY_MIRROR=${CI_NESTED_DOCKER_REGISTRY_MIRROR:-}
RUNNER_LABEL="easyai-gateway-ci-unprivileged-v2:docker://$JOB_IMAGE"
fail() {
echo "$*" >&2
exit 1
}
case $MIN_FREE_GIB:$MIN_POST_INSTALL_FREE_GIB in
*[!0-9:]* | :* | *:)
fail "CI runner free-space limits must be whole GiB values"
;;
esac
[ "$MIN_FREE_GIB" -ge 8 ] || fail "CI_RUNNER_MIN_FREE_GIB cannot be lower than 8"
[ "$MIN_POST_INSTALL_FREE_GIB" -ge 4 ] || \
fail "CI_RUNNER_MIN_POST_INSTALL_FREE_GIB cannot be lower than 4"
[ "$(id -u)" -eq 0 ] || fail "run as root"
[ "$(uname -s)-$(uname -m)" = "Linux-x86_64" ] || fail "only Linux x86_64 is supported"
case $RUNNER_IMAGE in
*"/runner:${GITEA_RUNNER_VERSION}-dind-rootless@sha256:"*) ;;
*) fail "RUNNER_IMAGE does not match GITEA_RUNNER_VERSION" ;;
esac
runner_manifest_digest=${RUNNER_IMAGE##*@}
case $RUNNER_IMAGE_SOURCE in
*@"$runner_manifest_digest") ;;
*) fail "CI_RUNNER_IMAGE_SOURCE must use the pinned runner manifest digest" ;;
esac
if [ -n "$NESTED_DOCKER_REGISTRY_MIRROR" ] && \
! printf '%s\n' "$NESTED_DOCKER_REGISTRY_MIRROR" | \
grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?$'; then
fail "CI_NESTED_DOCKER_REGISTRY_MIRROR must be an HTTPS registry origin"
fi
command -v docker >/dev/null 2>&1 || fail "Docker Engine is required"
docker info >/dev/null 2>&1 || fail "Docker Engine is not reachable"
grep -Fq "\"$RUNNER_LABEL\"" "$ROOT/deploy/ci/act-runner-v2-config.yaml" || \
fail "runner label does not match the pinned job image"
# Fail closed before installing anything. Neither the legacy host runner nor an
# earlier v2 unit may poll for work while its replacement is being prepared.
systemctl disable --now easyai-gateway-act-runner.service >/dev/null 2>&1 || true
systemctl disable --now easyai-gateway-ci-v2-runner.service >/dev/null 2>&1 || true
docker rm --force "$RUNNER_CONTAINER" >/dev/null 2>&1 || true
rm -f /etc/systemd/system/easyai-gateway-act-runner.service
if id easyai-gateway-runner >/dev/null 2>&1; then
if getent group docker >/dev/null 2>&1; then
gpasswd -d easyai-gateway-runner docker >/dev/null 2>&1 || true
fi
if getent group sudo >/dev/null 2>&1; then
gpasswd -d easyai-gateway-runner sudo >/dev/null 2>&1 || true
fi
fi
if id easyai-gateway-ci-v2 >/dev/null 2>&1; then
if getent group docker >/dev/null 2>&1; then
gpasswd -d easyai-gateway-ci-v2 docker >/dev/null 2>&1 || true
fi
if getent group sudo >/dev/null 2>&1; then
gpasswd -d easyai-gateway-ci-v2 sudo >/dev/null 2>&1 || true
fi
fi
for account in easyai-gateway-runner easyai-gateway-ci-v2; do
if id "$account" >/dev/null 2>&1 && \
id -nG "$account" | tr ' ' '\n' | grep -Eq '^(docker|sudo)$'; then
fail "$account must not belong to a privileged group"
fi
done
if ! command -v make >/dev/null 2>&1 || ! command -v gcc >/dev/null 2>&1 || \
! command -v jq >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1 || \
! command -v xz >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
build-essential ca-certificates curl git jq perl unzip util-linux xz-utils
fi
docker_root=$(docker info --format '{{.DockerRootDir}}')
[ -n "$docker_root" ] || fail "Docker root directory could not be determined"
require_free_space() {
minimum_gib=$1
phase=$2
available_kib=$(df -Pk "$docker_root" | awk 'NR == 2 { print $4 }')
case $available_kib in
'' | *[!0-9]*) fail "free space could not be determined for $docker_root" ;;
esac
minimum_kib=$((minimum_gib * 1024 * 1024))
if [ "$available_kib" -lt "$minimum_kib" ]; then
echo "$phase requires at least ${minimum_gib} GiB free on the Docker filesystem" >&2
return 1
fi
}
# This builder belonged only to the retired gateway image-building runner. Cap
# its unused cache before adding the nested daemon; never prune global images,
# volumes, or caches owned by another service on this shared production host.
if docker buildx version >/dev/null 2>&1 && \
docker buildx inspect "$LEGACY_BUILDER" >/dev/null 2>&1; then
docker buildx prune --builder "$LEGACY_BUILDER" --force --max-used-space 512mb
fi
require_free_space "$MIN_FREE_GIB" "CI runner installation"
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 -
}
install -d -m 0755 "$PREFIX/bin" "$PREFIX/downloads" "$PREFIX/toolchains"
download_verified \
"https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" \
"$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" \
"$SHELLCHECK_SHA256"
rm -rf "$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}"
tar -xJf "$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" \
-C "$PREFIX/downloads"
install -m 0755 "$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}/shellcheck" "$PREFIX/bin/shellcheck"
download_verified \
"https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64" \
"$PREFIX/downloads/docker-compose-${COMPOSE_VERSION}-linux-x86_64" \
"$COMPOSE_SHA256"
install -m 0755 "$PREFIX/downloads/docker-compose-${COMPOSE_VERSION}-linux-x86_64" "$PREFIX/bin/docker-compose"
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
PATH="$PREFIX/toolchains/node/bin:$PATH" \
"$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
require_free_space "$MIN_FREE_GIB" "runner image pull"
docker pull "$RUNNER_IMAGE_SOURCE"
runner_runtime_image=$(docker image inspect --format '{{.Id}}' "$RUNNER_IMAGE_SOURCE")
printf '%s\n' "$runner_runtime_image" | grep -Eq '^sha256:[0-9a-f]{64}$' || \
fail "pinned runner image did not resolve to an immutable local image ID"
chown -R root:root "$PREFIX"
chmod -R go-w "$PREFIX"
require_free_space "$MIN_POST_INSTALL_FREE_GIB" "completed runner image pull"
docker volume create "$RUNNER_VOLUME" >/dev/null
# The registered runner state and the nested rootless daemon cache share one
# dedicated volume. Persisting the pinned job image lets validation finish
# before registration and avoids racing the first queued workflow for layers.
docker run --rm --pull=never --user 0:0 \
--volume "$RUNNER_VOLUME:/data" \
--entrypoint /bin/sh "$runner_runtime_image" \
-ec 'chown 1000:1000 /data && chmod 0700 /data'
install -d -m 0755 /etc/easyai-gateway-ci-v2
if [ -n "$NESTED_DOCKER_REGISTRY_MIRROR" ]; then
printf '{"data-root":"/data/docker","registry-mirrors":["%s"]}\n' "$NESTED_DOCKER_REGISTRY_MIRROR" \
> /etc/easyai-gateway-ci-v2/daemon.json
else
printf '{"data-root":"/data/docker"}\n' > /etc/easyai-gateway-ci-v2/daemon.json
fi
chmod 0644 /etc/easyai-gateway-ci-v2/daemon.json
# Docker's official rootless DinD baseline is --privileged. Gitea's example
# additionally names an AppArmor profile, but Docker does not create that host
# profile. Enable it only when this daemon proves it can start the pinned image
# with the profile; otherwise leave the option empty.
RUNNER_SECURITY_OPTIONS=
if docker run --rm --pull=never --privileged \
--security-opt apparmor=rootlesskit \
--entrypoint /bin/true "$runner_runtime_image" >/dev/null 2>&1; then
RUNNER_SECURITY_OPTIONS="--security-opt=apparmor=rootlesskit"
elif [ -r /proc/sys/kernel/apparmor_restrict_unprivileged_userns ] && \
[ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" = "1" ]; then
fail "restricted AppArmor user namespaces require the rootlesskit profile (install it with the host Docker packages)"
fi
# Exercise the actual daemon startup before registration. This probe has no
# token, state volume, host bind mount, or socket mount.
PROBE_CONTAINER="easyai-gateway-ci-v2-probe-$$"
cleanup_probe() {
docker rm --force "$PROBE_CONTAINER" >/dev/null 2>&1 || true
}
trap 'cleanup_probe' EXIT
trap 'cleanup_probe; exit 129' HUP
trap 'cleanup_probe; exit 130' INT
trap 'cleanup_probe; exit 143' TERM
if [ -n "$RUNNER_SECURITY_OPTIONS" ]; then
set -- "$RUNNER_SECURITY_OPTIONS"
else
set --
fi
docker run --detach --rm --pull=never \
--name "$PROBE_CONTAINER" --privileged \
--cpus "$RUNNER_CPUS" --memory "$RUNNER_MEMORY" \
--memory-swap "$RUNNER_MEMORY_SWAP" --pids-limit "$RUNNER_PIDS_LIMIT" "$@" \
--volume "$RUNNER_VOLUME:/data" \
--volume /etc/easyai-gateway-ci-v2/daemon.json:/home/rootless/.config/docker/daemon.json:ro \
--volume "$PREFIX:$PREFIX:ro" \
--env DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns \
--env DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520 \
--env DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--pidns \
--entrypoint dockerd-entrypoint.sh "$runner_runtime_image" >/dev/null
attempt=0
until docker exec "$PROBE_CONTAINER" docker info >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ] || \
[ "$(docker inspect --format '{{.State.Running}}' "$PROBE_CONTAINER" 2>/dev/null || true)" != "true" ]; then
docker logs "$PROBE_CONTAINER" 2>&1 || true
fail "pinned rootless Docker-in-Docker probe did not become ready"
fi
sleep 2
done
[ "$(docker exec "$PROBE_CONTAINER" id -u)" = "1000" ] || \
fail "rootless Docker-in-Docker probe is not uid 1000"
[ "$(docker inspect --format '{{.HostConfig.Memory}}:{{.HostConfig.MemorySwap}}:{{.HostConfig.NanoCpus}}:{{.HostConfig.PidsLimit}}' "$PROBE_CONTAINER")" = \
'2147483648:3221225472:2000000000:1024' ] || \
fail "rootless Docker-in-Docker probe does not have the required aggregate resource limits"
docker exec "$PROBE_CONTAINER" docker info --format '{{range .SecurityOptions}}{{println .}}{{end}}' | \
grep -Fq 'name=rootless' || fail "rootless Docker-in-Docker probe is not rootless"
[ "$(docker exec "$PROBE_CONTAINER" docker info --format '{{.DockerRootDir}}')" = /data/docker ] || \
fail "rootless Docker-in-Docker probe is not using its persistent dedicated data root"
# Complete every slow and capability-sensitive validation before registration;
# otherwise a queued workflow can race the installer for the same image layers.
docker exec "$PROBE_CONTAINER" docker pull "$JOB_IMAGE"
outer_sentinel_pid=$(docker exec "$PROBE_CONTAINER" /bin/sh -c \
'sleep 300 >/dev/null 2>&1 & printf "%s\n" "$!"')
case $outer_sentinel_pid in
'' | *[!0-9]*) fail "could not create the outer PID namespace sentinel" ;;
esac
if ! docker exec "$PROBE_CONTAINER" docker run --rm --pull=never \
--pid=host --entrypoint /bin/sh "$JOB_IMAGE" \
-ec 'test ! -e "/proc/$1"' sh "$outer_sentinel_pid"; then
docker exec "$PROBE_CONTAINER" kill "$outer_sentinel_pid" >/dev/null 2>&1 || true
fail "nested --pid=host can see the outer runner PID namespace"
fi
docker exec "$PROBE_CONTAINER" kill "$outer_sentinel_pid" >/dev/null 2>&1 || true
docker exec "$PROBE_CONTAINER" docker run --rm --pull=never \
--volume "$PREFIX:$PREFIX:ro" \
--entrypoint /bin/sh "$JOB_IMAGE" -ec '
export PATH=/opt/easyai-gateway-ci/bin:/opt/easyai-gateway-ci/toolchains/go/bin:/opt/easyai-gateway-ci/toolchains/node/bin:$PATH
go version
node --version
pnpm --version
docker-compose version
shellcheck --version
trivy --version
govulncheck -version
'
cleanup_probe
trap - EXIT HUP INT TERM
install -m 0644 "$ROOT/deploy/ci/act-runner-v2-config.yaml" /etc/easyai-gateway-ci-v2/config.yaml
umask 077
printf 'RUNNER_SECURITY_OPTIONS="%s"\nRUNNER_IMAGE_REF="%s"\n' \
"$RUNNER_SECURITY_OPTIONS" "$runner_runtime_image" \
> /etc/easyai-gateway-ci-v2/runner.env
install -m 0644 "$ROOT/deploy/ci/easyai-gateway-ci-v2-runner.service" \
/etc/systemd/system/easyai-gateway-ci-v2-runner.service
runner_is_registered() {
docker run --rm --pull=never \
--volume "$RUNNER_VOLUME:/data" \
--entrypoint /bin/sh "$runner_runtime_image" \
-ec 'test -s /data/.runner'
}
if ! runner_is_registered; then
[ -n "${RUNNER_REGISTRATION_TOKEN:-}" ] || \
fail "RUNNER_REGISTRATION_TOKEN is required for first registration"
docker rm --force easyai-gateway-ci-v2-register >/dev/null 2>&1 || true
printf '%s\n%s\n%s\n' "$GITEA_INSTANCE_URL" "$RUNNER_REGISTRATION_TOKEN" "$RUNNER_NAME" | \
docker run --rm --interactive --pull=never \
--name easyai-gateway-ci-v2-register \
--volume "$RUNNER_VOLUME:/data" \
--volume /etc/easyai-gateway-ci-v2/config.yaml:/config.yaml:ro \
--env HOME=/data \
--entrypoint /usr/local/bin/gitea-runner \
"$runner_runtime_image" --config /config.yaml register
fi
unset RUNNER_REGISTRATION_TOKEN
docker run --rm --pull=never \
--volume "$RUNNER_VOLUME:/data" \
--entrypoint /bin/sh "$runner_runtime_image" \
-ec 'test -s /data/.runner && chmod 0600 /data/.runner'
RUNNER_VALIDATED=0
cleanup_unvalidated_runner() {
if [ "$RUNNER_VALIDATED" -ne 1 ]; then
systemctl disable --now easyai-gateway-ci-v2-runner.service >/dev/null 2>&1 || true
docker rm --force "$RUNNER_CONTAINER" >/dev/null 2>&1 || true
fi
}
trap 'cleanup_unvalidated_runner' EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
systemctl daemon-reload
systemctl enable --now easyai-gateway-ci-v2-runner.service
attempt=0
until docker exec "$RUNNER_CONTAINER" docker info >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ]; then
systemctl --no-pager --full status easyai-gateway-ci-v2-runner.service || true
docker logs "$RUNNER_CONTAINER" 2>&1 || true
fail "rootless Docker daemon did not become ready"
fi
sleep 2
done
attempt=0
until docker logs "$RUNNER_CONTAINER" 2>&1 | grep -Fq 'declare successfully'; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ] || \
[ "$(docker inspect --format '{{.State.Running}}' "$RUNNER_CONTAINER" 2>/dev/null || true)" != "true" ]; then
docker logs "$RUNNER_CONTAINER" 2>&1 || true
fail "Gitea runner did not declare itself successfully"
fi
sleep 2
done
[ "$(docker inspect --format '{{.Config.User}}' "$RUNNER_CONTAINER")" = "rootless" ] || \
fail "outer runner container is not using the rootless image user"
[ "$(docker inspect --format '{{.HostConfig.Privileged}}' "$RUNNER_CONTAINER")" = "true" ] || \
fail "outer rootless DinD container is not privileged"
[ "$(docker inspect --format '{{.HostConfig.Memory}}:{{.HostConfig.MemorySwap}}:{{.HostConfig.NanoCpus}}:{{.HostConfig.PidsLimit}}' "$RUNNER_CONTAINER")" = \
'2147483648:3221225472:2000000000:1024' ] || \
fail "outer rootless DinD container does not have the required aggregate resource limits"
if docker inspect --format '{{range .Mounts}}{{println .Source}}{{end}}' "$RUNNER_CONTAINER" | \
grep -Eq '/var/run/docker\.sock|/run/docker\.sock'; then
fail "host Docker socket was mounted into the runner"
fi
docker exec "$RUNNER_CONTAINER" docker info --format '{{range .SecurityOptions}}{{println .}}{{end}}' | \
grep -Fq 'name=rootless' || fail "nested Docker daemon is not rootless"
[ "$(docker exec "$RUNNER_CONTAINER" docker info --format '{{.DockerRootDir}}')" = /data/docker ] || \
fail "nested Docker daemon is not using its persistent dedicated data root"
docker exec "$RUNNER_CONTAINER" docker image inspect "$JOB_IMAGE" >/dev/null || \
fail "validated pinned job image is unavailable after runner activation"
if ! require_free_space "$MIN_POST_INSTALL_FREE_GIB" "completed CI runner installation"; then
fail "CI runner was stopped because the post-install disk reserve was not met"
fi
systemctl --no-pager --full status easyai-gateway-ci-v2-runner.service
RUNNER_VALIDATED=1
trap - EXIT HUP INT TERM
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$root"
components=auto
production_host=${AI_GATEWAY_PRODUCTION_HOST:-root@110.42.51.33}
remote_helper=${AI_GATEWAY_REMOTE_RELEASE_HELPER:-/usr/local/sbin/easyai-ai-gateway-release}
registry=${AI_GATEWAY_IMAGE_REGISTRY:-registry.cn-shanghai.aliyuncs.com/easyaigc}
platform=linux/amd64
platform_probe_image=${AI_GATEWAY_PLATFORM_PROBE_IMAGE:-docker.m.daocloud.io/library/alpine:3.22}
status_file_override=${AI_GATEWAY_RELEASE_STATUS_FILE:-}
usage() {
cat <<'EOF'
Usage:
scripts/publish-release-images.sh [--components auto|api|web|all]
This command only builds, smoke-tests, and pushes immutable images. It never
changes production. The generated manifest must be deployed separately with
scripts/deploy-production-release.sh after explicit confirmation.
Environment:
AI_GATEWAY_PRODUCTION_HOST SSH target used for read-only release status
AI_GATEWAY_REMOTE_RELEASE_HELPER Fixed status/deploy helper on the server
AI_GATEWAY_IMAGE_REGISTRY Registry namespace
AI_GATEWAY_RELEASE_STATUS_FILE Local status manifest for offline/testing use
EOF
}
while [[ $# -gt 0 ]]; do
case $1 in
--components)
[[ $# -ge 2 ]] || { echo 'missing value for --components' >&2; exit 64; }
components=$2
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'unknown argument: %s\n' "$1" >&2
usage >&2
exit 64
;;
esac
done
case $components in
auto|api|web|all) ;;
*) echo '--components must be auto, api, web, or all' >&2; exit 64 ;;
esac
[[ $production_host =~ ^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$ ]] || {
echo 'AI_GATEWAY_PRODUCTION_HOST must use user@host syntax' >&2
exit 1
}
[[ $remote_helper =~ ^/[A-Za-z0-9._/-]+$ ]] || {
echo 'AI_GATEWAY_REMOTE_RELEASE_HELPER must be an absolute safe path' >&2
exit 1
}
[[ $registry =~ ^[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._-]+)+$ ]] || {
echo 'AI_GATEWAY_IMAGE_REGISTRY has an invalid format' >&2
exit 1
}
[[ $platform_probe_image =~ ^[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._-]+)*(:[A-Za-z0-9._-]+|@sha256:[0-9a-f]{64})$ ]] || {
echo 'AI_GATEWAY_PLATFORM_PROBE_IMAGE has an invalid format' >&2
exit 1
}
fail() {
echo "release_publish=FAIL $*" >&2
exit 1
}
for command in git node docker curl; do
command -v "$command" >/dev/null 2>&1 || fail "$command is required"
done
docker info >/dev/null 2>&1 || fail 'Docker Engine is not reachable'
docker buildx version >/dev/null 2>&1 || fail 'Docker Buildx is required'
docker compose version >/dev/null 2>&1 || fail 'Docker Compose v2 is required'
git fetch --quiet origin main
preflight_file=$(mktemp)
node scripts/release-preflight.mjs >"$preflight_file"
source_sha=$(node -e \
'const value=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(value.sourceSha)' \
"$preflight_file")
status_file=$(mktemp)
selection_file=$(mktemp)
smoke_project=easyai-release-smoke-${source_sha:0:12}
smoke_started=0
cleanup() {
local status=$?
trap - EXIT HUP INT TERM
if [[ $smoke_started -eq 1 ]]; then
COMPOSE_PROJECT_NAME=$smoke_project \
docker compose -p "$smoke_project" -f docker-compose.yml down -v --remove-orphans \
>/dev/null 2>&1 || true
fi
rm -f "$status_file" "$selection_file" "$preflight_file"
exit "$status"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
has_status=0
if [[ -n $status_file_override ]]; then
[[ -f $status_file_override ]] || fail 'AI_GATEWAY_RELEASE_STATUS_FILE does not exist'
cp "$status_file_override" "$status_file"
has_status=1
elif ssh -o BatchMode=yes -o ConnectTimeout=8 "$production_host" \
"$remote_helper status" >"$status_file" 2>/dev/null; then
has_status=1
fi
base_sha=
current_api_image=
current_web_image=
if [[ $has_status -eq 1 ]]; then
node scripts/release-manifest.mjs validate "$status_file" >/dev/null
base_sha=$(node scripts/release-manifest.mjs get "$status_file" sourceSha)
current_api_image=$(node scripts/release-manifest.mjs get "$status_file" images.api)
current_web_image=$(node scripts/release-manifest.mjs get "$status_file" images.web)
fi
if [[ $components != all && $has_status -ne 1 ]]; then
fail 'auto/api/web publishing requires a readable production release; use --components all for the first release'
fi
required_components=all
migrations_changed=true
base_verifiable=0
if [[ $has_status -eq 1 ]]; then
if ! git cat-file -e "$base_sha^{commit}" 2>/dev/null; then
git fetch --quiet origin "$base_sha" >/dev/null 2>&1 || true
fi
if git cat-file -e "$base_sha^{commit}" 2>/dev/null; then
git merge-base --is-ancestor "$base_sha" "$source_sha" || \
fail 'the production release is not an ancestor of the source commit'
base_verifiable=1
node scripts/release-components.mjs "$base_sha" "$source_sha" >"$selection_file"
required_components=$(node -e \
'const value=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(value.components)' \
"$selection_file")
migrations_changed=$(node -e \
'const value=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(String(value.migrationsChanged))' \
"$selection_file")
elif [[ $components != all ]]; then
fail 'the production source SHA cannot be verified; rerun with --components all'
else
echo '[release] production source SHA is unavailable; conservatively building all components and running migrations' >&2
fi
fi
if [[ $components == auto ]]; then
components=$required_components
fi
if [[ $components == none ]]; then
echo 'release_publish=SKIP reason=no_runtime_changes'
exit 0
fi
if [[ $required_components == all && $components != all ]]; then
fail "the source change requires both images; requested $components"
fi
if [[ $required_components == api && $components == web ]]; then
fail 'the source change requires the API image'
fi
if [[ $required_components == web && $components == api ]]; then
fail 'the source change requires the Web image'
fi
if [[ $migrations_changed == true && $components != api && $components != all ]]; then
fail 'migration changes require the API image'
fi
api_repository=$registry/ai-gateway
web_repository=$registry/ai-gateway-web
api_tag=$api_repository:$source_sha
web_tag=$web_repository:$source_sha
remote_tag_must_not_exist() {
local image=$1
local output status
set +e
output=$(docker buildx imagetools inspect "$image" 2>&1)
status=$?
set -e
if [[ $status -eq 0 ]]; then
fail "immutable image tag already exists: $image"
fi
if ! grep -Eqi 'not found|manifest unknown|does not exist' <<<"$output"; then
fail "cannot verify registry login and repository access for $image"
fi
}
case $components in
api) remote_tag_must_not_exist "$api_tag" ;;
web) remote_tag_must_not_exist "$web_tag" ;;
all)
remote_tag_must_not_exist "$api_tag"
remote_tag_must_not_exist "$web_tag"
;;
esac
docker run --rm --platform "$platform" "$platform_probe_image" true >/dev/null 2>&1 || \
fail "Docker cannot execute $platform containers"
if [[ $base_verifiable -eq 1 ]]; then
node scripts/ci-validate-migrations.mjs "$base_sha"
fi
echo '[release] running fast Go tests without database integration variables'
(
cd apps/api
env -u AI_GATEWAY_TEST_DATABASE_URL go test ./... -count=1
)
api_image=$current_api_image
web_image=$current_web_image
build_image() {
local target=$1
local image=$2
local -a build_args=()
case $target in
api)
build_args=(
--build-arg "GOPROXY=${AI_GATEWAY_GO_PROXY:-https://goproxy.cn,direct}"
--build-arg "GO_BUILD_IMAGE=${AI_GATEWAY_GO_BUILD_IMAGE:-golang:1.26.3-alpine}"
--build-arg "API_RUNTIME_IMAGE=${AI_GATEWAY_API_RUNTIME_IMAGE:-alpine:3.22}"
)
;;
web)
build_args=(
--build-arg "VITE_GATEWAY_API_BASE_URL=${AI_GATEWAY_WEB_API_BASE_URL:-/gateway-api}"
--build-arg "NODE_BUILD_IMAGE=${AI_GATEWAY_NODE_BUILD_IMAGE:-node:22-alpine}"
--build-arg "WEB_RUNTIME_IMAGE=${AI_GATEWAY_WEB_RUNTIME_IMAGE:-nginx:1.27-alpine}"
--build-arg "NPM_CONFIG_REGISTRY=${AI_GATEWAY_NPM_REGISTRY:-https://registry.npmjs.org}"
--build-arg "VITE_BASE_PATH=${AI_GATEWAY_WEB_BASE_PATH:-/}"
)
;;
esac
echo "[release] building $target for $platform: $image"
docker buildx build \
--platform "$platform" \
--file Dockerfile \
--target "$target" \
--label "org.opencontainers.image.revision=$source_sha" \
--label 'org.opencontainers.image.source=https://git.51easyai.com/BCAI/easyai-ai-gateway' \
--tag "$image" \
--load \
"${build_args[@]}" \
.
[[ $(docker image inspect "$image" --format '{{.Os}}/{{.Architecture}}') == "$platform" ]] || \
fail "$image is not $platform"
[[ $(docker image inspect "$image" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}') == "$source_sha" ]] || \
fail "$image is missing the source revision label"
}
case $components in
api)
build_image api "$api_tag"
api_image=$api_tag
;;
web)
build_image web "$web_tag"
web_image=$web_tag
;;
all)
build_image api "$api_tag"
build_image web "$web_tag"
api_image=$api_tag
web_image=$web_tag
;;
esac
for image in "$api_image" "$web_image"; do
docker image inspect "$image" >/dev/null 2>&1 || docker pull --platform "$platform" "$image"
done
build_completed_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
export COMPOSE_PROJECT_NAME=$smoke_project
export AI_GATEWAY_PLATFORM=$platform
export AI_GATEWAY_API_IMAGE=$api_image
export AI_GATEWAY_WEB_IMAGE=$web_image
export AI_GATEWAY_API_PORT=0
export AI_GATEWAY_WEB_PORT=0
export AI_GATEWAY_DB_PORT=0
export AI_GATEWAY_COMPOSE_APP_ENV=test
export AI_GATEWAY_COMPOSE_IDENTITY_MODE=hybrid
export AI_GATEWAY_COMPOSE_DATABASE_NAME=easyai_ai_gateway
export AI_GATEWAY_COMPOSE_PG_USER=easyai
export AI_GATEWAY_COMPOSE_PG_PASSWORD=release-smoke-postgres
export AI_GATEWAY_COMPOSE_DATABASE_URL='postgresql://easyai:release-smoke-postgres@postgres:5432/easyai_ai_gateway?sslmode=disable'
export CONFIG_JWT_SECRET=release-smoke-$source_sha
smoke_started=1
docker compose -p "$smoke_project" -f docker-compose.yml up -d --pull never postgres
for _ in $(seq 1 60); do
postgres_id=$(docker compose -p "$smoke_project" -f docker-compose.yml ps -q postgres)
postgres_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$postgres_id" 2>/dev/null || true)
[[ $postgres_health == healthy ]] && break
sleep 1
done
[[ ${postgres_health:-} == healthy ]] || fail 'ephemeral PostgreSQL did not become healthy'
docker compose -p "$smoke_project" -f docker-compose.yml run --rm --no-deps migrator
docker compose -p "$smoke_project" -f docker-compose.yml up -d --pull never --no-build api web
api_port=$(docker compose -p "$smoke_project" -f docker-compose.yml port api 8088 | tail -n 1)
web_port=$(docker compose -p "$smoke_project" -f docker-compose.yml port web 80 | tail -n 1)
api_port=${api_port##*:}
web_port=${web_port##*:}
[[ $api_port =~ ^[0-9]+$ && $web_port =~ ^[0-9]+$ ]] || fail 'could not resolve smoke ports'
RELEASE_SMOKE_BASE_URL=http://127.0.0.1:$api_port \
RELEASE_SMOKE_WEB_URL=http://127.0.0.1:$web_port \
RELEASE_SMOKE_COMPOSE_PROJECT=$smoke_project \
RELEASE_SMOKE_COMPOSE_FILE=$root/docker-compose.yml \
RELEASE_SMOKE_NONCE=${source_sha:0:12} \
RELEASE_SMOKE_DATABASE_USER=$AI_GATEWAY_COMPOSE_PG_USER \
RELEASE_SMOKE_DATABASE_NAME=$AI_GATEWAY_COMPOSE_DATABASE_NAME \
node scripts/api-release-smoke.mjs
smoke_completed_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
push_and_resolve() {
local image=$1
local repository=$2
local resolved
docker push "$image" >&2
resolved=$(docker image inspect "$image" --format '{{range .RepoDigests}}{{println .}}{{end}}' | \
awk -v prefix="$repository@sha256:" 'index($0, prefix) == 1 { print; exit }')
[[ $resolved =~ ^[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._-]+)+@sha256:[0-9a-f]{64}$ ]] || \
fail "could not resolve registry digest for $image"
printf '%s\n' "$resolved"
}
case $components in
api)
api_image=$(push_and_resolve "$api_tag" "$api_repository")
component_list=api
;;
web)
web_image=$(push_and_resolve "$web_tag" "$web_repository")
component_list=web
;;
all)
api_image=$(push_and_resolve "$api_tag" "$api_repository")
web_image=$(push_and_resolve "$web_tag" "$web_repository")
component_list=api,web
;;
esac
mkdir -p dist/releases
manifest=$root/dist/releases/$source_sha.json
[[ ! -e $manifest ]] || fail "release manifest already exists: $manifest"
RELEASE_SOURCE_SHA=$source_sha \
RELEASE_BASE_SHA=$base_sha \
RELEASE_COMPONENTS=$component_list \
RELEASE_MIGRATIONS_CHANGED=$migrations_changed \
RELEASE_API_IMAGE=$api_image \
RELEASE_WEB_IMAGE=$web_image \
RELEASE_BUILD_COMPLETED_AT=$build_completed_at \
RELEASE_SMOKE_COMPLETED_AT=$smoke_completed_at \
node scripts/release-manifest.mjs create "$manifest"
echo "release_publish=PASS release=$source_sha components=$component_list"
echo "release_manifest=$manifest"
echo "api_image=$api_image"
echo "web_image=$web_image"
echo 'production_changed=false'
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
function fail(message) {
console.error(`release_components=FAIL ${message}`)
process.exit(1)
}
const base = process.argv[2] || ''
const head = process.argv[3] || 'HEAD'
if (!/^[0-9a-f]{40}$/.test(base)) {
fail('base must be a full lowercase Git SHA')
}
function git(args) {
try {
return execFileSync('git', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim()
} catch (error) {
const stderr = error.stderr?.toString().trim()
fail(stderr ? `git_error=${JSON.stringify(stderr)}` : 'git command failed')
}
}
const resolvedBase = git(['rev-parse', '--verify', `${base}^{commit}`])
const resolvedHead = git(['rev-parse', '--verify', `${head}^{commit}`])
if (resolvedBase !== base) fail('base did not resolve exactly')
if (!/^[0-9a-f]{40}$/.test(resolvedHead)) fail('head did not resolve to a commit')
try {
execFileSync('git', ['merge-base', '--is-ancestor', base, resolvedHead], {
stdio: 'ignore',
})
} catch {
fail('base is not an ancestor of head')
}
const changed = git([
'diff',
'--name-only',
'--no-renames',
`${base}..${resolvedHead}`,
]).split('\n').filter(Boolean)
const apiPatterns = [
/^apps\/api\//,
/^go\.work(?:\.sum)?$/,
]
const webPatterns = [
/^apps\/web\//,
/^packages\/contracts\//,
/^pnpm-lock\.yaml$/,
/^pnpm-workspace\.yaml$/,
/^nx\.json$/,
/^package\.json$/,
/^docker\/nginx\.conf$/,
]
const sharedRuntimePatterns = [
/^\.dockerignore$/,
/^docker-compose(?:\.[^.]+)?\.ya?ml$/,
]
function dockerfileSections(ref) {
let contents
try {
contents = execFileSync('git', ['show', `${ref}:Dockerfile`], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
} catch {
return null
}
const sections = new Map([['global', []]])
let section = 'global'
for (const line of contents.split('\n')) {
const match = line.match(/^FROM\s+.+?\s+AS\s+([A-Za-z0-9._-]+)(?:\s|$)/i)
if (match) section = match[1].toLowerCase()
if (!sections.has(section)) sections.set(section, [])
sections.get(section).push(line)
}
return sections
}
function sectionChanged(before, after, name) {
return (before.get(name) || []).join('\n') !== (after.get(name) || []).join('\n')
}
function classifyDockerfile() {
const before = dockerfileSections(base)
const after = dockerfileSections(resolvedHead)
if (!before || !after) return { apiChanged: true, webChanged: true }
const knownApi = new Set(['api-builder', 'api'])
const knownWeb = new Set(['web-builder', 'web'])
let apiChanged = false
let webChanged = false
const beforeGlobal = before.get('global') || []
const afterGlobal = after.get('global') || []
const globalLines = (lines, pattern) => lines.filter((line) => pattern.test(line)).join('\n')
const apiGlobal = /\b(?:GO_VERSION|GO_BUILD_IMAGE|API_RUNTIME_IMAGE)\b/
const webGlobal = /\b(?:NODE_VERSION|NODE_BUILD_IMAGE|WEB_RUNTIME_IMAGE)\b/
if (globalLines(beforeGlobal, apiGlobal) !== globalLines(afterGlobal, apiGlobal)) apiChanged = true
if (globalLines(beforeGlobal, webGlobal) !== globalLines(afterGlobal, webGlobal)) webChanged = true
const sharedGlobal = (lines) => lines.filter((line) => !apiGlobal.test(line) && !webGlobal.test(line)).join('\n')
if (sharedGlobal(beforeGlobal) !== sharedGlobal(afterGlobal)) {
apiChanged = true
webChanged = true
}
const sectionNames = new Set([...before.keys(), ...after.keys()])
sectionNames.delete('global')
for (const name of sectionNames) {
if (!sectionChanged(before, after, name)) continue
if (knownApi.has(name)) apiChanged = true
else if (knownWeb.has(name)) webChanged = true
else {
apiChanged = true
webChanged = true
}
}
return { apiChanged, webChanged }
}
let api = false
let web = false
let migrationsChanged = false
if (changed.includes('Dockerfile')) {
const dockerfile = classifyDockerfile()
api = dockerfile.apiChanged
web = dockerfile.webChanged
}
for (const file of changed) {
if (file === 'Dockerfile') continue
if (/^apps\/api\/migrations\//.test(file)) migrationsChanged = true
if (sharedRuntimePatterns.some((pattern) => pattern.test(file))) {
api = true
web = true
continue
}
if (apiPatterns.some((pattern) => pattern.test(file))) api = true
if (webPatterns.some((pattern) => pattern.test(file))) web = true
}
const components = api && web ? 'all' : api ? 'api' : web ? 'web' : 'none'
console.log(JSON.stringify({
base,
head: resolvedHead,
components,
migrationsChanged,
changedFiles: changed,
}))
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { readFileSync, writeFileSync } from 'node:fs'
const shaPattern = /^[0-9a-f]{40}$/
const digestImagePattern = /^[A-Za-z0-9.-]+(?::[0-9]+)?(?:\/[A-Za-z0-9._-]+)+@sha256:[0-9a-f]{64}$/
const allowedComponents = new Set(['api', 'web'])
const smokeChecks = [
'health',
'readiness',
'openapi',
'register',
'login',
'apiKey',
'models',
'chatCompletions',
'responses',
'images',
'web',
]
function requireExactKeys(value, keys, path) {
const actual = Object.keys(value).sort()
const expected = [...keys].sort()
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
fail(`${path} has unexpected or missing fields`)
}
}
function releaseIntegrity(manifest) {
const payload = structuredClone(manifest)
delete payload.integrity
delete payload.deployment
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
}
function fail(message) {
console.error(`release_manifest=FAIL ${message}`)
process.exit(1)
}
function load(path) {
try {
return JSON.parse(readFileSync(path, 'utf8'))
} catch (error) {
fail(`cannot read JSON manifest: ${error.message}`)
}
}
function validate(manifest) {
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
fail('manifest root must be an object')
}
const rootKeys = [
'schemaVersion', 'releaseId', 'sourceSha', 'baseReleaseSha', 'sourceRef',
'createdAt', 'buildCompletedAt', 'targetPlatform', 'components',
'migrationsChanged', 'images', 'smoke', 'integrity',
]
if (manifest.deployment !== undefined) rootKeys.push('deployment')
requireExactKeys(manifest, rootKeys, 'manifest')
if (manifest.schemaVersion !== 1) fail('schemaVersion must be 1')
if (!shaPattern.test(manifest.sourceSha || '')) fail('sourceSha must be a full lowercase Git SHA')
if (manifest.releaseId !== manifest.sourceSha) fail('releaseId must equal sourceSha')
if (manifest.sourceRef !== 'refs/heads/main') fail('sourceRef must be refs/heads/main')
if (manifest.baseReleaseSha !== null && !shaPattern.test(manifest.baseReleaseSha || '')) {
fail('baseReleaseSha must be null or a full lowercase Git SHA')
}
if (manifest.baseReleaseSha === manifest.sourceSha) fail('baseReleaseSha must differ from sourceSha')
if (manifest.targetPlatform !== 'linux/amd64') fail('targetPlatform must be linux/amd64')
if (!Number.isFinite(Date.parse(manifest.createdAt))) fail('createdAt must be an ISO timestamp')
if (!Number.isFinite(Date.parse(manifest.buildCompletedAt))) {
fail('buildCompletedAt must be an ISO timestamp')
}
if (!Array.isArray(manifest.components) || manifest.components.length < 1 || manifest.components.length > 2) {
fail('components must contain api, web, or both')
}
const uniqueComponents = [...new Set(manifest.components)]
if (uniqueComponents.length !== manifest.components.length ||
uniqueComponents.some((component) => !allowedComponents.has(component))) {
fail('components contains an invalid or duplicate value')
}
if (typeof manifest.migrationsChanged !== 'boolean') fail('migrationsChanged must be boolean')
if (manifest.migrationsChanged && !manifest.components.includes('api')) {
fail('migration changes require the api component')
}
if (!manifest.images || typeof manifest.images !== 'object') fail('images must be an object')
requireExactKeys(manifest.images, ['api', 'web'], 'images')
for (const component of allowedComponents) {
if (!digestImagePattern.test(manifest.images[component] || '')) {
fail(`images.${component} must be a registry digest reference`)
}
}
if (!manifest.smoke || manifest.smoke.status !== 'passed' || manifest.smoke.mode !== 'simulation') {
fail('smoke must record a passed simulation check')
}
requireExactKeys(manifest.smoke, ['status', 'mode', 'completedAt', 'checks'], 'smoke')
if (!Number.isFinite(Date.parse(manifest.smoke.completedAt))) {
fail('smoke.completedAt must be an ISO timestamp')
}
if (!Array.isArray(manifest.smoke.checks) ||
manifest.smoke.checks.length !== smokeChecks.length ||
manifest.smoke.checks.some((check, index) => check !== smokeChecks[index])) {
fail('smoke.checks is incomplete or out of order')
}
if (!manifest.integrity || typeof manifest.integrity !== 'object') {
fail('integrity must be an object')
}
requireExactKeys(manifest.integrity, ['algorithm', 'value'], 'integrity')
if (manifest.integrity.algorithm !== 'sha256' || !/^[0-9a-f]{64}$/.test(manifest.integrity.value || '')) {
fail('integrity must contain a SHA-256 value')
}
if (releaseIntegrity(manifest) !== manifest.integrity.value) {
fail('manifest content integrity check failed')
}
if (manifest.deployment !== undefined) {
if (!manifest.deployment || typeof manifest.deployment !== 'object') {
fail('deployment must be an object')
}
requireExactKeys(manifest.deployment, ['status', 'action', 'deployedAt', 'durationSeconds'], 'deployment')
if (manifest.deployment.status !== 'passed' ||
!['deploy', 'rollback'].includes(manifest.deployment.action) ||
!Number.isFinite(Date.parse(manifest.deployment.deployedAt)) ||
!Number.isInteger(manifest.deployment.durationSeconds) ||
manifest.deployment.durationSeconds < 0) {
fail('deployment record is invalid')
}
}
const forbiddenKey = /(password|secret|token|credential|private.?key)/i
const visit = (value, path = '') => {
if (!value || typeof value !== 'object') return
for (const [key, child] of Object.entries(value)) {
const childPath = path ? `${path}.${key}` : key
if (forbiddenKey.test(key)) fail(`forbidden sensitive field: ${childPath}`)
visit(child, childPath)
}
}
visit(manifest)
return manifest
}
function requireEnv(name, pattern) {
const value = process.env[name] || ''
if (!value || (pattern && !pattern.test(value))) fail(`invalid or missing ${name}`)
return value
}
const [command = 'validate', path, field] = process.argv.slice(2)
if (command === 'create') {
if (!path) fail('create requires an output path')
const sourceSha = requireEnv('RELEASE_SOURCE_SHA', shaPattern)
const baseReleaseSha = process.env.RELEASE_BASE_SHA || null
if (baseReleaseSha !== null && !shaPattern.test(baseReleaseSha)) fail('invalid RELEASE_BASE_SHA')
const components = requireEnv('RELEASE_COMPONENTS').split(',').filter(Boolean)
const migrationsChanged = process.env.RELEASE_MIGRATIONS_CHANGED === 'true'
const manifest = {
schemaVersion: 1,
releaseId: sourceSha,
sourceSha,
baseReleaseSha,
sourceRef: 'refs/heads/main',
createdAt: new Date().toISOString(),
buildCompletedAt: requireEnv('RELEASE_BUILD_COMPLETED_AT'),
targetPlatform: 'linux/amd64',
components,
migrationsChanged,
images: {
api: requireEnv('RELEASE_API_IMAGE', digestImagePattern),
web: requireEnv('RELEASE_WEB_IMAGE', digestImagePattern),
},
smoke: {
status: 'passed',
mode: 'simulation',
completedAt: requireEnv('RELEASE_SMOKE_COMPLETED_AT'),
checks: smokeChecks,
},
integrity: { algorithm: 'sha256', value: '' },
}
manifest.integrity.value = releaseIntegrity(manifest)
validate(manifest)
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
const digest = createHash('sha256').update(readFileSync(path)).digest('hex')
console.log(`release_manifest=CREATED path=${path} sha256=${digest}`)
process.exit(0)
}
if (!path) fail(`${command} requires a manifest path`)
const manifest = validate(load(path))
if (command === 'record-deployment') {
if (!field) fail('record-deployment requires an output path')
const deployedAt = requireEnv('RELEASE_DEPLOYED_AT')
const durationSeconds = Number(process.env.RELEASE_DEPLOY_DURATION_SECONDS || '')
const action = process.env.RELEASE_DEPLOY_ACTION || 'deploy'
if (!Number.isFinite(Date.parse(deployedAt))) fail('invalid RELEASE_DEPLOYED_AT')
if (!Number.isInteger(durationSeconds) || durationSeconds < 0) {
fail('invalid RELEASE_DEPLOY_DURATION_SECONDS')
}
if (!['deploy', 'rollback'].includes(action)) fail('invalid RELEASE_DEPLOY_ACTION')
manifest.deployment = { status: 'passed', action, deployedAt, durationSeconds }
validate(manifest)
writeFileSync(field, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
console.log(`release_manifest=RECORDED release=${manifest.releaseId} action=${action}`)
process.exit(0)
}
if (command === 'validate') {
const digest = createHash('sha256').update(readFileSync(path)).digest('hex')
console.log(`release_manifest=PASS release=${manifest.releaseId} sha256=${digest}`)
process.exit(0)
}
if (command === 'get') {
if (!field || !/^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/.test(field)) {
fail('get requires a safe dotted field path')
}
let value = manifest
for (const segment of field.split('.')) value = value?.[segment]
if (typeof value === 'object') console.log(JSON.stringify(value))
else if (value === null || value === undefined) console.log('')
else console.log(String(value))
process.exit(0)
}
fail(`unknown command: ${command}`)
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
import { execFileSync, spawnSync } from 'node:child_process'
function fail(message) {
console.error(`release_preflight=FAIL ${message}`)
process.exit(1)
}
function git(args) {
try {
return execFileSync('git', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim()
} catch (error) {
const stderr = error.stderr?.toString().trim()
fail(stderr ? `git_error=${JSON.stringify(stderr)}` : 'git command failed')
}
}
const root = git(['rev-parse', '--show-toplevel'])
process.chdir(root)
for (const args of [['diff', '--quiet'], ['diff', '--cached', '--quiet']]) {
if (spawnSync('git', args, { stdio: 'ignore' }).status !== 0) {
fail('the worktree must be clean')
}
}
if (git(['ls-files', '--others', '--exclude-standard'])) {
fail('the worktree must not contain untracked files')
}
const sourceSha = git(['rev-parse', '--verify', 'HEAD^{commit}'])
if (!/^[0-9a-f]{40}$/.test(sourceSha)) fail('HEAD is not a full Git commit')
const mainSha = git(['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'])
if (!/^[0-9a-f]{40}$/.test(mainSha)) fail('origin/main is unavailable')
if (spawnSync('git', ['merge-base', '--is-ancestor', sourceSha, mainSha], { stdio: 'ignore' }).status !== 0) {
fail('HEAD is not part of origin/main history')
}
console.log(JSON.stringify({ sourceSha, originMainSha: mainSha }))