diff --git a/.gitea/workflows/docker-image.yml b/.gitea/workflows/docker-image.yml index 28d4d0b8..7d327d84 100644 --- a/.gitea/workflows/docker-image.yml +++ b/.gitea/workflows/docker-image.yml @@ -131,6 +131,22 @@ jobs: docker push "$IMAGE_VERSION_TAG" docker push "$IMAGE_LATEST_TAG" + - name: Prune Gitea registry images + env: + GITEA_API_BASE_URL: ${{ secrets.GITEA_API_BASE_URL }} + GITEA_PACKAGE_TOKEN: ${{ secrets.GITEA_PACKAGE_TOKEN }} + run: | + set -eu + + if [ -z "$GITEA_PACKAGE_TOKEN" ]; then + echo "Missing secret: GITEA_PACKAGE_TOKEN" >&2 + exit 1 + fi + + GITEA_API_BASE_URL="${GITEA_API_BASE_URL:-https://gitea.banlv-ai.com/api/v1}" \ + GITEA_PACKAGE_KEEP_COUNT=3 \ + bash scripts/ci/prune-gitea-container-images.sh + - name: Deploy image over SSH env: SSH_HOST: ${{ secrets.SSH_HOST }} @@ -201,8 +217,13 @@ jobs: | ssh $SSH_OPTS "$SSH_TARGET" \ "docker login '$REGISTRY_HOST' -u '$REGISTRY_USERNAME' --password-stdin" + SERVER_RETAIN_COUNT="0" + if [ "$DEPLOY_ENV" = "prod" ]; then + SERVER_RETAIN_COUNT="1" + fi + ssh $SSH_OPTS "$SSH_TARGET" \ - "cd '$DEPLOY_DIR' && chmod +x ./deploy-docker-image.sh && ./deploy-docker-image.sh '$IMAGE_VERSION_TAG' '$NEXT_ENV_FILE' '$HOST_PORT' '$CONTAINER_NAME' '$PROJECT_NAME'" + "cd '$DEPLOY_DIR' && chmod +x ./deploy-docker-image.sh && ./deploy-docker-image.sh '$IMAGE_VERSION_TAG' '$NEXT_ENV_FILE' '$HOST_PORT' '$CONTAINER_NAME' '$PROJECT_NAME' '$SERVER_RETAIN_COUNT'" - name: Purge Cloudflare cache env: @@ -230,6 +251,6 @@ jobs: if: always() env: ACTION_JOB_STATUS: ${{ job.status }} - FLOW_NOTIFY_BASE_URL: ${{ secrets.FLOW_NOTIFY_BASE_URL }} + ACTION_NOTIFY_WEBHOOK_URL: ${{ secrets.ACTION_NOTIFY_WEBHOOK_URL }} run: | bash scripts/ci/notify-actions.sh diff --git a/docs/docker-image-ci.md b/docs/docker-image-ci.md index b32cc466..b9dbb8c8 100644 --- a/docs/docker-image-ci.md +++ b/docs/docker-image-ci.md @@ -23,6 +23,8 @@ | `REGISTRY_IMAGE` | `gitea.banlv-ai.com/admin/cozsweet-web` | 完整镜像仓库名,不包含 tag | | `REGISTRY_USERNAME` | `cozsweet-bot` | 镜像仓库用户名 | | `REGISTRY_PASSWORD` | `***` | 镜像仓库密码或访问令牌 | +| `GITEA_API_BASE_URL` | `https://gitea.banlv-ai.com/api/v1` | Gitea API 地址,用于清理旧镜像版本 | +| `GITEA_PACKAGE_TOKEN` | `***` | 具备 package 删除权限的 Gitea token | | `TEST_ENV_FILE` | `.env.local` 的完整内容 | 测试环境构建期环境变量 | | `PRODUCTION_ENV_FILE` | `.env.production` 的完整内容 | 生产环境构建期环境变量 | @@ -106,3 +108,13 @@ gitea.banlv-ai.com/admin/cozsweet-web:prod- ``` 推荐优先使用默认的 `-` 精确 tag,这样发布和回滚都更可控。 + +## 镜像保留策略 + +镜像推送成功后,workflow 会调用 Gitea Package API 清理远端 Container Registry: + +- `test-*` 精确 tag 只保留最近 3 个,`test-latest` 不计入也不删除。 +- `prod-*` 精确 tag 只保留最近 3 个,`prod-latest` 不计入也不删除。 +- 当前刚推送的 `IMAGE_VERSION_TAG` 永远不会被删除。 + +生产环境 SSH 部署成功后,服务器本机只保留当前正在运行的一个 `prod-*` 镜像;测试环境服务器本机暂不限制保留数量。 diff --git a/docs/gitea-actions-ssh-deploy.md b/docs/gitea-actions-ssh-deploy.md index f46d70be..e7c4faff 100644 --- a/docs/gitea-actions-ssh-deploy.md +++ b/docs/gitea-actions-ssh-deploy.md @@ -29,6 +29,8 @@ push test/main | `REGISTRY_IMAGE` | 镜像仓库,例如 `gitea.banlv-ai.com/admin/cozsweet-web` | | `REGISTRY_USERNAME` | registry 用户名 | | `REGISTRY_PASSWORD` | registry 密码或 token | +| `GITEA_API_BASE_URL` | Gitea API 地址,例如 `https://gitea.banlv-ai.com/api/v1` | +| `GITEA_PACKAGE_TOKEN` | 具备 package 删除权限的 Gitea token,用于清理旧镜像 tag | | `TEST_ENV_FILE` | 测试环境 `.env.local` 内容 | | `PRODUCTION_ENV_FILE` | 生产环境 `.env.production` 内容 | @@ -61,6 +63,19 @@ deploy-docker-image.sh .env.local 或 .env.production ``` +## 镜像保留策略 + +Actions 推送镜像后会清理 Gitea Container Registry,同一环境只保留最近 3 个精确版本 tag: + +```text +test- 最近 3 个 +prod- 最近 3 个 +``` + +`test-latest` / `prod-latest` 不计入保留数量,也不会主动删除。 + +生产环境部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前 `prod-` 镜像。测试环境服务器本机镜像暂不限制保留数量。 + ## 本地发布入口 本地发布脚本现在只负责推送 Gitea 分支,触发 Actions: diff --git a/scripts/ci/notify-actions.sh b/scripts/ci/notify-actions.sh index 691eb78e..a9b59602 100644 --- a/scripts/ci/notify-actions.sh +++ b/scripts/ci/notify-actions.sh @@ -5,28 +5,6 @@ warn() { printf 'Notification warning: %s\n' "$*" >&2 } -generate_chat_id() { - if [ -n "${ACTION_NOTIFY_CHAT_ID:-}" ]; then - printf '%s' "$ACTION_NOTIFY_CHAT_ID" - return 0 - fi - - if command -v node >/dev/null 2>&1; then - node <<'NODE' -const { randomUUID } = require("node:crypto"); -process.stdout.write(`oc_${randomUUID().replaceAll("-", "")}`); -NODE - return 0 - fi - - if command -v openssl >/dev/null 2>&1; then - printf 'oc_%s' "$(openssl rand -hex 16)" - return 0 - fi - - printf 'oc_%s%s' "$(date +%s)" "$$" -} - branch_name="${BRANCH_NAME:-${GITHUB_REF_NAME:-}}" if [ -z "$branch_name" ] && [ -n "${GITHUB_REF:-}" ]; then branch_name="${GITHUB_REF#refs/heads/}" @@ -43,9 +21,11 @@ if [ -z "$short_sha" ]; then short_sha="unknown" fi -base_url="${FLOW_NOTIFY_BASE_URL:-https://flow.banlv-ai.com}" -base_url="${base_url%/}" -chat_id="$(generate_chat_id)" +notify_url="${ACTION_NOTIFY_WEBHOOK_URL:-}" +if [ -z "$notify_url" ]; then + warn "ACTION_NOTIFY_WEBHOOK_URL is not configured, skip notification." + exit 0 +fi deploy_env="${DEPLOY_ENV:-unknown}" image_tag="${IMAGE_VERSION_TAG:-unknown}" @@ -84,11 +64,6 @@ process.stdout.write(JSON.stringify(payload)); NODE )" -encoded_chat_id="$( - node -e 'process.stdout.write(encodeURIComponent(process.argv[1] || ""))' "$chat_id" -)" -notify_url="$base_url/webhooks/action-notify?chat_id=$encoded_chat_id" - if [ "${FLOW_NOTIFY_DRY_RUN:-}" = "1" ]; then printf 'Action notification dry run URL: %s\n' "$notify_url" printf 'Action notification dry run payload: %s\n' "$payload" @@ -99,8 +74,8 @@ if ! curl -fsS \ -X POST "$notify_url" \ -H "Content-Type: application/json" \ -d "$payload" >/dev/null; then - warn "failed to send action notification for $chat_id" + warn "failed to send action notification." exit 0 fi -echo "Sent action notification for $chat_id." +echo "Sent action notification." diff --git a/scripts/ci/prune-gitea-container-images.sh b/scripts/ci/prune-gitea-container-images.sh new file mode 100755 index 00000000..b41cade8 --- /dev/null +++ b/scripts/ci/prune-gitea-container-images.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +set -euo pipefail + +KEEP_COUNT="${GITEA_PACKAGE_KEEP_COUNT:-3}" +API_BASE_URL="${GITEA_API_BASE_URL:-https://gitea.banlv-ai.com/api/v1}" +TOKEN="${GITEA_PACKAGE_TOKEN:-}" +REGISTRY_IMAGE="${REGISTRY_IMAGE:-${IMAGE:-}}" +DEPLOY_ENV="${DEPLOY_ENV:-}" +CURRENT_IMAGE_TAG="${IMAGE_VERSION_TAG:-}" + +if [ -z "$TOKEN" ] && [ -z "${GITEA_PACKAGE_VERSIONS_FILE:-}" ]; then + echo "Missing GITEA_PACKAGE_TOKEN" >&2 + exit 1 +fi + +if [ -z "$REGISTRY_IMAGE" ] || [ -z "$DEPLOY_ENV" ]; then + echo "Missing REGISTRY_IMAGE/IMAGE or DEPLOY_ENV" >&2 + exit 1 +fi + +case "$DEPLOY_ENV" in + test|prod) ;; + *) + echo "Unsupported DEPLOY_ENV for package pruning: $DEPLOY_ENV" >&2 + exit 1 + ;; +esac + +if ! command -v node >/dev/null 2>&1; then + echo "node is required for package pruning" >&2 + exit 127 +fi + +if ! command -v curl >/dev/null 2>&1; then + echo "curl is required for package pruning" >&2 + exit 127 +fi + +image_without_tag="${REGISTRY_IMAGE%%:*}" +path_without_registry="${image_without_tag#*/}" +owner="${path_without_registry%%/*}" +package="${path_without_registry#*/}" + +if [ "$owner" = "$path_without_registry" ] || [ -z "$owner" ] || [ -z "$package" ]; then + echo "Could not parse owner/package from REGISTRY_IMAGE: $REGISTRY_IMAGE" >&2 + exit 1 +fi + +current_version="${CURRENT_IMAGE_TAG##*:}" +version_prefix="${DEPLOY_ENV}-" +latest_version="${DEPLOY_ENV}-latest" +api_base="${API_BASE_URL%/}" + +echo "=== prune Gitea container package versions ===" +echo "owner: $owner" +echo "package: $package" +echo "environment: $DEPLOY_ENV" +echo "keep count: $KEEP_COUNT" + +versions_json="$(mktemp)" +delete_list="$(mktemp)" +cleanup() { + rm -f "$versions_json" "$delete_list" +} +trap cleanup EXIT + +if [ -n "${GITEA_PACKAGE_VERSIONS_FILE:-}" ]; then + cp "$GITEA_PACKAGE_VERSIONS_FILE" "$versions_json" +else + curl -fsS \ + -H "Authorization: token $TOKEN" \ + -H "Accept: application/json" \ + "$api_base/packages/$owner/container/$package" \ + -o "$versions_json" +fi + +KEEP_COUNT="$KEEP_COUNT" \ +VERSION_PREFIX="$version_prefix" \ +LATEST_VERSION="$latest_version" \ +CURRENT_VERSION="$current_version" \ +VERSIONS_JSON_FILE="$versions_json" \ +node <<'NODE' > "$delete_list" +const fs = require("node:fs"); + +const file = process.env.VERSIONS_JSON_FILE; +const keepCount = Number.parseInt(process.env.KEEP_COUNT || "3", 10); +const prefix = process.env.VERSION_PREFIX || ""; +const latest = process.env.LATEST_VERSION || ""; +const current = process.env.CURRENT_VERSION || ""; +const raw = JSON.parse(fs.readFileSync(file, "utf8")); +const list = Array.isArray(raw) ? raw : Array.isArray(raw.versions) ? raw.versions : []; + +function versionOf(item) { + return String(item.version || item.name || item.tag_name || "").trim(); +} + +function createdAtOf(item) { + return String( + item.created_at || + item.created || + item.createdAt || + item.published_at || + item.updated_at || + "", + ); +} + +const candidates = list + .map((item) => ({ + version: versionOf(item), + createdAt: createdAtOf(item), + })) + .filter(({ version }) => { + if (!version.startsWith(prefix)) return false; + if (version === latest) return false; + return true; + }) + .sort((a, b) => { + const bTime = Date.parse(b.createdAt); + const aTime = Date.parse(a.createdAt); + if (Number.isFinite(bTime) && Number.isFinite(aTime) && bTime !== aTime) { + return bTime - aTime; + } + return b.version.localeCompare(a.version); + }); + +const kept = new Set(candidates.slice(0, Math.max(0, keepCount)).map((item) => item.version)); +const toDelete = candidates + .slice(Math.max(0, keepCount)) + .map((item) => item.version) + .filter((version) => version !== current && !kept.has(version)); +process.stdout.write(toDelete.join("\n")); +if (toDelete.length > 0) process.stdout.write("\n"); +NODE + +if [ ! -s "$delete_list" ]; then + echo "No old $DEPLOY_ENV package versions to delete." + exit 0 +fi + +echo "Package versions selected for deletion:" +sed 's/^/ - /' "$delete_list" + +if [ "${PRUNE_DRY_RUN:-}" = "1" ]; then + echo "Dry run enabled, skip DELETE requests." + exit 0 +fi + +while IFS= read -r version; do + [ -n "$version" ] || continue + encoded_owner="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1] || ""))' "$owner")" + encoded_package="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1] || ""))' "$package")" + encoded_version="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1] || ""))' "$version")" + curl -fsS \ + -X DELETE \ + -H "Authorization: token $TOKEN" \ + "$api_base/packages/$encoded_owner/container/$encoded_package/$encoded_version" \ + >/dev/null + echo "Deleted package version: $version" +done < "$delete_list" + +echo "=== package prune done ===" diff --git a/scripts/server/deploy-docker-image.sh b/scripts/server/deploy-docker-image.sh index 5dfbd6aa..4f613c3f 100755 --- a/scripts/server/deploy-docker-image.sh +++ b/scripts/server/deploy-docker-image.sh @@ -7,9 +7,10 @@ ENV_FILE="${2:-}" HOST_PORT="${3:-}" CONTAINER_NAME="${4:-}" PROJECT_NAME="${5:-}" +SERVER_RETAIN_COUNT="${6:-0}" if [ -z "$IMAGE" ] || [ -z "$ENV_FILE" ] || [ -z "$HOST_PORT" ] || [ -z "$CONTAINER_NAME" ] || [ -z "$PROJECT_NAME" ]; then - echo "Usage: $0 " >&2 + echo "Usage: $0 [server-retain-count]" >&2 exit 64 fi @@ -50,4 +51,38 @@ echo "=== compose project: $COMPOSE_PROJECT_NAME ===" docker image prune -f +prune_project_images() { + local retain_count="$1" + if ! [[ "$retain_count" =~ ^[0-9]+$ ]] || [ "$retain_count" -le 0 ]; then + return 0 + fi + + local image_repo="${IMAGE%%:*}" + local image_tag="${IMAGE##*:}" + local tag_prefix="${image_tag%%-*}-" + + if [ -z "$image_repo" ] || [ "$image_repo" = "$IMAGE" ] || [ -z "$tag_prefix" ]; then + echo "Skip project image prune: could not parse image repo/tag from $IMAGE" + return 0 + fi + + echo "=== prune local project images: keep $retain_count for $tag_prefix ===" + + docker image ls "$image_repo" \ + --format '{{.Repository}}:{{.Tag}} {{.CreatedAt}}' \ + | awk -v prefix="$tag_prefix" '$1 ~ ":" prefix && $1 !~ /-latest$/ { print }' \ + | sort -k2,3r \ + | awk -v keep="$retain_count" 'NR > keep { print $1 }' \ + | while IFS= read -r old_image; do + [ -n "$old_image" ] || continue + if [ "$old_image" = "$IMAGE" ]; then + continue + fi + echo "Removing local image: $old_image" + docker image rm "$old_image" || true + done +} + +prune_project_images "$SERVER_RETAIN_COUNT" + echo "=== deploy done ==="