Files
easyai-ai-gateway/tests/release/manual-release-test.sh
T
easyai dae5d16a58 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 完整栈冒烟。
2026-07-22 15:13:40 +08:00

262 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
new_repo() {
local name=$1
local repo=$tmp/$name
mkdir -p "$repo/apps/api/migrations" "$repo/apps/web/src" "$repo/packages/contracts/src" "$repo/docs"
git -C "$repo" init -q
git -C "$repo" config user.name 'Manual Release Test'
git -C "$repo" config user.email 'manual-release-test@example.invalid'
printf 'package api\n' >"$repo/apps/api/main.go"
printf 'SELECT 1;\n' >"$repo/apps/api/migrations/0001_init.sql"
printf 'web\n' >"$repo/apps/web/src/app.ts"
printf 'contracts\n' >"$repo/packages/contracts/src/index.ts"
# shellcheck disable=SC2016
printf 'ARG API_RUNTIME_IMAGE=scratch\nARG WEB_RUNTIME_IMAGE=scratch\nFROM ${API_RUNTIME_IMAGE} AS api\nCOPY apps/api /app\nFROM ${WEB_RUNTIME_IMAGE} AS web\nCOPY apps/web /web\n' >"$repo/Dockerfile"
printf 'docs\n' >"$repo/docs/readme.md"
git -C "$repo" add -A
git -C "$repo" commit -qm baseline
printf '%s\n' "$repo"
}
classify() {
local repo=$1
local base=$2
(cd "$repo" && node "$root/scripts/release-components.mjs" "$base")
}
repo=$(new_repo api)
base=$(git -C "$repo" rev-parse HEAD)
printf 'package api\n// changed\n' >"$repo/apps/api/main.go"
git -C "$repo" add -A && git -C "$repo" commit -qm api
result=$(classify "$repo" "$base")
node -e 'const value=JSON.parse(process.argv[1]); if(value.components!=="api"||value.migrationsChanged)process.exit(1)' "$result"
repo=$(new_repo web)
base=$(git -C "$repo" rev-parse HEAD)
printf 'web changed\n' >"$repo/apps/web/src/app.ts"
git -C "$repo" add -A && git -C "$repo" commit -qm web
result=$(classify "$repo" "$base")
node -e 'const value=JSON.parse(process.argv[1]); if(value.components!=="web")process.exit(1)' "$result"
repo=$(new_repo all)
base=$(git -C "$repo" rev-parse HEAD)
printf 'node_modules\n' >"$repo/.dockerignore"
git -C "$repo" add -A && git -C "$repo" commit -qm all
result=$(classify "$repo" "$base")
node -e 'const value=JSON.parse(process.argv[1]); if(value.components!=="all")process.exit(1)' "$result"
repo=$(new_repo api-docker-stage)
base=$(git -C "$repo" rev-parse HEAD)
# shellcheck disable=SC2016
printf 'ARG API_RUNTIME_IMAGE=scratch\nARG WEB_RUNTIME_IMAGE=scratch\nFROM ${API_RUNTIME_IMAGE} AS api\nCOPY apps/api /app\nRUN echo api\nFROM ${WEB_RUNTIME_IMAGE} AS web\nCOPY apps/web /web\n' >"$repo/Dockerfile"
git -C "$repo" add -A && git -C "$repo" commit -qm api-docker-stage
result=$(classify "$repo" "$base")
node -e 'const value=JSON.parse(process.argv[1]); if(value.components!=="api")process.exit(1)' "$result"
repo=$(new_repo web-docker-stage)
base=$(git -C "$repo" rev-parse HEAD)
printf '\nRUN echo web\n' >>"$repo/Dockerfile"
git -C "$repo" add -A && git -C "$repo" commit -qm web-docker-stage
result=$(classify "$repo" "$base")
node -e 'const value=JSON.parse(process.argv[1]); if(value.components!=="web")process.exit(1)' "$result"
repo=$(new_repo migration)
base=$(git -C "$repo" rev-parse HEAD)
printf 'ALTER TABLE demo ADD COLUMN note text;\n' >"$repo/apps/api/migrations/0002_note.sql"
git -C "$repo" add -A && git -C "$repo" commit -qm migration
result=$(classify "$repo" "$base")
node -e 'const value=JSON.parse(process.argv[1]); if(value.components!=="api"||!value.migrationsChanged)process.exit(1)' "$result"
repo=$(new_repo docs)
base=$(git -C "$repo" rev-parse HEAD)
printf 'docs changed\n' >"$repo/docs/readme.md"
git -C "$repo" add -A && git -C "$repo" commit -qm docs
result=$(classify "$repo" "$base")
node -e 'const value=JSON.parse(process.argv[1]); if(value.components!=="none")process.exit(1)' "$result"
bare=$tmp/origin.git
git init -q --bare "$bare"
repo=$(new_repo preflight)
git -C "$repo" branch -M main
git -C "$repo" remote add origin "$bare"
git -C "$repo" push -q -u origin main
(cd "$repo" && node "$root/scripts/release-preflight.mjs" >/dev/null)
printf 'dirty\n' >>"$repo/docs/readme.md"
if (cd "$repo" && node "$root/scripts/release-preflight.mjs" >/dev/null 2>&1); then
echo 'dirty worktree passed release preflight' >&2
exit 1
fi
git -C "$repo" restore docs/readme.md
printf 'untracked\n' >"$repo/untracked"
if (cd "$repo" && node "$root/scripts/release-preflight.mjs" >/dev/null 2>&1); then
echo 'untracked file passed release preflight' >&2
exit 1
fi
rm "$repo/untracked"
git -C "$repo" switch -q -c feature
printf 'feature\n' >>"$repo/docs/readme.md"
git -C "$repo" add -A && git -C "$repo" commit -qm feature
if (cd "$repo" && node "$root/scripts/release-preflight.mjs" >/dev/null 2>&1); then
echo 'non-main commit passed release preflight' >&2
exit 1
fi
manifest=$tmp/release.json
recorded=$tmp/recorded.json
source_sha=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
base_sha=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
api_image=registry.example.com/easyai/ai-gateway@sha256:$(printf '1%.0s' {1..64})
web_image=registry.example.com/easyai/ai-gateway-web@sha256:$(printf '2%.0s' {1..64})
RELEASE_SOURCE_SHA=$source_sha \
RELEASE_BASE_SHA=$base_sha \
RELEASE_COMPONENTS=api \
RELEASE_MIGRATIONS_CHANGED=false \
RELEASE_API_IMAGE=$api_image \
RELEASE_WEB_IMAGE=$web_image \
RELEASE_BUILD_COMPLETED_AT=2026-07-22T00:00:00Z \
RELEASE_SMOKE_COMPLETED_AT=2026-07-22T00:00:00Z \
node "$root/scripts/release-manifest.mjs" create "$manifest" >/dev/null
node "$root/scripts/release-manifest.mjs" validate "$manifest" >/dev/null
[[ $(node "$root/scripts/release-manifest.mjs" get "$manifest" images.api) == "$api_image" ]]
RELEASE_DEPLOYED_AT=2026-07-22T00:01:00Z \
RELEASE_DEPLOY_DURATION_SECONDS=17 \
RELEASE_DEPLOY_ACTION=deploy \
node "$root/scripts/release-manifest.mjs" record-deployment "$manifest" "$recorded" >/dev/null
node "$root/scripts/release-manifest.mjs" validate "$recorded" >/dev/null
sensitive=$tmp/sensitive.json
node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1])); value.accessToken="forbidden"; fs.writeFileSync(process.argv[2], JSON.stringify(value))' "$manifest" "$sensitive"
if node "$root/scripts/release-manifest.mjs" validate "$sensitive" >/dev/null 2>&1; then
echo 'sensitive manifest field passed validation' >&2
exit 1
fi
tampered=$tmp/tampered.json
node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1])); value.images.api=value.images.api.replace(/^registry[.]/, "mirror."); fs.writeFileSync(process.argv[2], JSON.stringify(value))' "$manifest" "$tampered"
if node "$root/scripts/release-manifest.mjs" validate "$tampered" >/dev/null 2>&1; then
echo 'tampered manifest content passed validation' >&2
exit 1
fi
incomplete_digest=$tmp/incomplete-digest.json
node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1])); value.images.web="registry.example.com/easyai/ai-gateway-web:tag"; fs.writeFileSync(process.argv[2], JSON.stringify(value))' "$manifest" "$incomplete_digest"
if node "$root/scripts/release-manifest.mjs" validate "$incomplete_digest" >/dev/null 2>&1; then
echo 'tag-based image passed manifest validation' >&2
exit 1
fi
if grep -Eqi 'password|secret|token|credential|private.?key' "$manifest"; then
echo 'generated manifest contains a sensitive field name' >&2
exit 1
fi
publisher_repo=$tmp/publisher
mkdir -p "$publisher_repo/scripts" "$publisher_repo/apps/api" "$publisher_repo/fake-bin"
cp "$root/scripts/publish-release-images.sh" \
"$root/scripts/release-preflight.mjs" \
"$root/scripts/release-manifest.mjs" \
"$root/scripts/release-components.mjs" \
"$root/scripts/ci-validate-migrations.mjs" \
"$publisher_repo/scripts/"
printf 'FROM scratch\n' >"$publisher_repo/Dockerfile"
printf 'module example.invalid/release-test\n\ngo 1.26\n' >"$publisher_repo/apps/api/go.mod"
cat >"$publisher_repo/fake-bin/docker" <<'EOF'
#!/usr/bin/env bash
case "$*" in
info|'buildx version'|'compose version') exit 0 ;;
'buildx imagetools inspect '*)
if [[ ${FAKE_DOCKER_MODE:-} == registry ]]; then
echo 'unauthorized: authentication required' >&2
else
echo 'manifest unknown: not found' >&2
fi
exit 1
;;
'run --rm --platform linux/amd64 '*) exit 1 ;;
esac
echo "unexpected fake docker call: $*" >&2
exit 1
EOF
cat >"$publisher_repo/fake-bin/ssh" <<'EOF'
#!/usr/bin/env bash
exit 1
EOF
chmod +x "$publisher_repo/scripts/"* "$publisher_repo/fake-bin/"*
git -C "$publisher_repo" init -q
git -C "$publisher_repo" config user.name 'Manual Release Test'
git -C "$publisher_repo" config user.email 'manual-release-test@example.invalid'
git -C "$publisher_repo" add -A
git -C "$publisher_repo" commit -qm baseline
git -C "$publisher_repo" branch -M main
publisher_bare=$tmp/publisher-origin.git
git init -q --bare "$publisher_bare"
git -C "$publisher_repo" remote add origin "$publisher_bare"
git -C "$publisher_repo" push -q -u origin main
if (cd "$publisher_repo" && PATH="$publisher_repo/fake-bin:$PATH" \
./scripts/publish-release-images.sh --components auto >/dev/null 2>&1); then
echo 'first release guessed components in auto mode' >&2
exit 1
fi
if (cd "$publisher_repo" && PATH="$publisher_repo/fake-bin:$PATH" FAKE_DOCKER_MODE=registry \
./scripts/publish-release-images.sh --components all >/dev/null 2>&1); then
echo 'publisher passed without registry access' >&2
exit 1
fi
if (cd "$publisher_repo" && PATH="$publisher_repo/fake-bin:$PATH" FAKE_DOCKER_MODE=platform \
./scripts/publish-release-images.sh --components all >/dev/null 2>&1); then
echo 'publisher passed without linux/amd64 execution support' >&2
exit 1
fi
current_manifest=$tmp/current.json
RELEASE_SOURCE_SHA=cccccccccccccccccccccccccccccccccccccccc \
RELEASE_BASE_SHA=dddddddddddddddddddddddddddddddddddddddd \
RELEASE_COMPONENTS=web \
RELEASE_MIGRATIONS_CHANGED=false \
RELEASE_API_IMAGE=$api_image \
RELEASE_WEB_IMAGE=$web_image \
RELEASE_BUILD_COMPLETED_AT=2026-07-22T00:02:00Z \
RELEASE_SMOKE_COMPLETED_AT=2026-07-22T00:02:00Z \
node "$root/scripts/release-manifest.mjs" create "$current_manifest" >/dev/null
stale_bin=$tmp/stale-bin
mkdir -p "$stale_bin"
cat >"$stale_bin/ssh" <<'EOF'
#!/usr/bin/env bash
if [[ $* == *' status' ]]; then
cat "$STALE_STATUS_FILE"
fi
exit 0
EOF
cat >"$stale_bin/scp" <<'EOF'
#!/usr/bin/env bash
: >"$STALE_SCP_MARKER"
exit 0
EOF
chmod +x "$stale_bin/"*
stale_marker=$tmp/stale-scp-called
if PATH="$stale_bin:$PATH" STALE_STATUS_FILE=$current_manifest STALE_SCP_MARKER=$stale_marker \
"$root/scripts/deploy-production-release.sh" "$manifest" >/dev/null 2>&1; then
echo 'stale production base passed deploy client validation' >&2
exit 1
fi
[[ ! -e $stale_marker ]] || {
echo 'stale deploy uploaded a manifest' >&2
exit 1
}
if find "$root/.gitea/workflows" -type f -print -quit 2>/dev/null | grep -q .; then
echo 'automatic Gitea workflow still exists' >&2
exit 1
fi
grep -Fq 'stale release manifest' "$root/deploy/manual/easyai-ai-gateway-release"
grep -Fq 'production_changed=false' "$root/scripts/publish-release-images.sh"
echo 'manual_release_tests=PASS'