From 43471631d81ea73aec743d0b0f72e929cafefc4d Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Mon, 27 Jul 2026 13:02:24 +0800 Subject: [PATCH 1/2] Keep upgrade validation compatible with any source release The upgrade test previously special-cased v1.11.2 and let the target image migrate beyond the source image's known Alembic graph. Discover the source image heads and only use target migration code to repair to that boundary when source bootstrap fails. Constraint: The upgrade source is selected dynamically from the previous release tag Rejected: Pin a known revision | breaks when the previous release changes Confidence: high Scope-risk: narrow Reversibility: clean Directive: Never expose target-only revisions to the source image before source assertions complete Tested: sh -n; bash -n; Alembic head and history resolution; git diff --check Not-tested: Full Docker upgrade workflow because the local Docker daemon is unavailable --- .github/scripts/ci_upgrade_test.sh | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/scripts/ci_upgrade_test.sh b/.github/scripts/ci_upgrade_test.sh index dd1ed9105..0ccb62a73 100644 --- a/.github/scripts/ci_upgrade_test.sh +++ b/.github/scripts/ci_upgrade_test.sh @@ -76,13 +76,25 @@ docker image inspect "$OLD_IMAGE" --format '{{ index .Config.Labels "org.opencon OLD_VERSION=$(cat /tmp/old_version | tr -d '\r') echo "启动升级源 version=$OLD_VERSION revision=$OLD_REVISION" -if [ "$OLD_VERSION" = "v1.11.2" ]; then - echo "v1.11.2 无法从空库执行最终 migration,先建立其父 revision" - run_schema_command "$OLD_IMAGE" \ - "alembic upgrade add_experience_revision_drafts" - echo "使用目标镜像执行修复后的最终 migration" - run_schema_command "$NEW_IMAGE" \ - "alembic upgrade head && python -m app.scripts.setup_langgraph_checkpoints" +echo "使用升级源镜像建立旧版本 schema" +if ! run_schema_command "$OLD_IMAGE" "alembic upgrade head"; then + echo "升级源镜像无法从空库完成 migration,使用目标镜像修复到升级源可识别的 head" + OLD_SCHEMA_HEADS=$(run_schema_command "$OLD_IMAGE" "alembic heads" | awk '$NF == "(head)" { print $1 }') + if [ -z "$OLD_SCHEMA_HEADS" ]; then + echo "无法识别升级源 Alembic head" + exit 1 + fi + + for OLD_SCHEMA_HEAD in $OLD_SCHEMA_HEADS; do + case "$OLD_SCHEMA_HEAD" in + *[!A-Za-z0-9_.-]*) + echo "升级源 Alembic head 格式无效: $OLD_SCHEMA_HEAD" + exit 1 + ;; + esac + echo "使用目标镜像迁移到升级源 head=$OLD_SCHEMA_HEAD" + run_schema_command "$NEW_IMAGE" "alembic upgrade $OLD_SCHEMA_HEAD" + done fi docker run -d \ From 146e90f78004320c1b889b2a945665d9d6f8f34d Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Mon, 27 Jul 2026 13:44:25 +0800 Subject: [PATCH 2/2] Preserve the source schema before repairing release heads Derive source heads and their direct parents from the source image, commit the historical parent schema with source migrations, and permit target code to replace only a changed source-head migration. Verify the stored Alembic heads before starting the source app without bootstrap. Constraint: Target Base.metadata must never bootstrap the source-version database Rejected: Retry the target image from an empty database | fabricates target schema at a source revision Rejected: Run every revision in a separate container | preserves boundaries but adds unnecessary CI startup overhead Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep target migrations behind a committed source-schema boundary Tested: sh, bash, and dash syntax; 17 scoped pytest tests; v1.11.2 graph and digest resolution; git diff check Not-tested: Full Docker upgrade workflow because the local Docker daemon is unavailable --- .github/scripts/ci_upgrade_test.sh | 109 ++++++++++++++++++++++++----- 1 file changed, 93 insertions(+), 16 deletions(-) diff --git a/.github/scripts/ci_upgrade_test.sh b/.github/scripts/ci_upgrade_test.sh index 0ccb62a73..1032d4ffa 100644 --- a/.github/scripts/ci_upgrade_test.sh +++ b/.github/scripts/ci_upgrade_test.sh @@ -59,6 +59,26 @@ run_schema_command() { "$IMAGE" -lc "$COMMAND" } +run_schema_python() { + IMAGE="$1" + PYTHON_CODE="$2" + docker run --rm \ + --network "$NETWORK" \ + --entrypoint python \ + -e DATABASE_URL=postgresql+asyncpg://clawith:clawith@postgres:5432/clawith \ + -e REDIS_URL=redis://redis:6379/0 \ + -e SECRET_KEY=ci-test-secret \ + -e JWT_SECRET_KEY=ci-test-jwt-secret \ + "$IMAGE" -c "$PYTHON_CODE" +} + +schema_revision_digest() { + IMAGE="$1" + REVISION="$2" + run_schema_python "$IMAGE" \ + "import hashlib; from alembic.config import Config; from alembic.script import ScriptDirectory; script = ScriptDirectory.from_config(Config(\"alembic.ini\")); revision = script.get_revision(\"$REVISION\"); print(hashlib.sha256(open(revision.path, \"rb\").read()).hexdigest())" +} + trap cleanup EXIT compose down -v --remove-orphans >/dev/null 2>&1 || true @@ -76,27 +96,82 @@ docker image inspect "$OLD_IMAGE" --format '{{ index .Config.Labels "org.opencon OLD_VERSION=$(cat /tmp/old_version | tr -d '\r') echo "启动升级源 version=$OLD_VERSION revision=$OLD_REVISION" -echo "使用升级源镜像建立旧版本 schema" -if ! run_schema_command "$OLD_IMAGE" "alembic upgrade head"; then - echo "升级源镜像无法从空库完成 migration,使用目标镜像修复到升级源可识别的 head" - OLD_SCHEMA_HEADS=$(run_schema_command "$OLD_IMAGE" "alembic heads" | awk '$NF == "(head)" { print $1 }') - if [ -z "$OLD_SCHEMA_HEADS" ]; then - echo "无法识别升级源 Alembic head" - exit 1 +SOURCE_SCHEMA_HEADS=$(run_schema_python "$OLD_IMAGE" \ + 'from alembic.config import Config; from alembic.script import ScriptDirectory; script = ScriptDirectory.from_config(Config("alembic.ini")); print("\n".join(script.get_heads()))') +SOURCE_SCHEMA_PARENT_REVISIONS=$(run_schema_python "$OLD_IMAGE" \ + 'from alembic.config import Config; from alembic.script import ScriptDirectory; script = ScriptDirectory.from_config(Config("alembic.ini")); normalize = lambda value: () if value is None else (value,) if isinstance(value, str) else tuple(value); print("\n".join(sorted({parent for head in script.get_heads() for parent in normalize(script.get_revision(head).down_revision)})))') +SOURCE_SCHEMA_ROOT_HEADS=$(run_schema_python "$OLD_IMAGE" \ + 'from alembic.config import Config; from alembic.script import ScriptDirectory; script = ScriptDirectory.from_config(Config("alembic.ini")); normalize = lambda value: () if value is None else (value,) if isinstance(value, str) else tuple(value); print("\n".join(sorted(head for head in script.get_heads() if not normalize(script.get_revision(head).down_revision))))') + +if [ -z "$SOURCE_SCHEMA_HEADS" ]; then + echo "无法识别升级源 Alembic revision graph" + exit 1 +fi + +echo "使用升级源镜像建立并提交 source head 的父 revision" +# 父 revision 必须完全由升级源镜像建立;任何中间 migration 失败都会直接终止。 +for SOURCE_SCHEMA_PARENT in $SOURCE_SCHEMA_PARENT_REVISIONS; do + case "$SOURCE_SCHEMA_PARENT" in + *[!A-Za-z0-9_.-]*) + echo "升级源 Alembic parent revision 格式无效: $SOURCE_SCHEMA_PARENT" + exit 1 + ;; + esac + run_schema_command "$OLD_IMAGE" "alembic upgrade $SOURCE_SCHEMA_PARENT" +done + +echo "使用升级源镜像逐个提交 source head" +# source head 使用独立事务,失败时不会回滚已经提交的历史 schema。 +for SOURCE_SCHEMA_HEAD in $SOURCE_SCHEMA_HEADS; do + case "$SOURCE_SCHEMA_HEAD" in + *[!A-Za-z0-9_.-]*) + echo "升级源 Alembic head 格式无效: $SOURCE_SCHEMA_HEAD" + exit 1 + ;; + esac + + if run_schema_command "$OLD_IMAGE" "alembic upgrade $SOURCE_SCHEMA_HEAD"; then + continue fi - for OLD_SCHEMA_HEAD in $OLD_SCHEMA_HEADS; do - case "$OLD_SCHEMA_HEAD" in - *[!A-Za-z0-9_.-]*) - echo "升级源 Alembic head 格式无效: $OLD_SCHEMA_HEAD" - exit 1 - ;; - esac - echo "使用目标镜像迁移到升级源 head=$OLD_SCHEMA_HEAD" - run_schema_command "$NEW_IMAGE" "alembic upgrade $OLD_SCHEMA_HEAD" + SOURCE_HEAD_IS_ROOT=false + for SOURCE_SCHEMA_ROOT_HEAD in $SOURCE_SCHEMA_ROOT_HEADS; do + if [ "$SOURCE_SCHEMA_ROOT_HEAD" = "$SOURCE_SCHEMA_HEAD" ]; then + SOURCE_HEAD_IS_ROOT=true + break + fi done + + if [ "$SOURCE_HEAD_IS_ROOT" = "true" ]; then + echo "升级源 root head 失败,禁止由目标镜像从空库构造旧 schema: $SOURCE_SCHEMA_HEAD" + exit 1 + fi + + SOURCE_HEAD_DIGEST=$(schema_revision_digest "$OLD_IMAGE" "$SOURCE_SCHEMA_HEAD") + TARGET_HEAD_DIGEST=$(schema_revision_digest "$NEW_IMAGE" "$SOURCE_SCHEMA_HEAD") + if [ "$SOURCE_HEAD_DIGEST" = "$TARGET_HEAD_DIGEST" ]; then + echo "目标镜像没有该 source head 的修复版本,拒绝掩盖 migration 错误: $SOURCE_SCHEMA_HEAD" + exit 1 + fi + + echo "升级源 head migration 失败,使用目标镜像仅修复相同 head=$SOURCE_SCHEMA_HEAD" + run_schema_command "$NEW_IMAGE" "alembic upgrade $SOURCE_SCHEMA_HEAD" +done + +EXPECTED_SOURCE_SCHEMA_HEADS=$(printf '%s\n' "$SOURCE_SCHEMA_HEADS" | sort) +ACTUAL_SOURCE_SCHEMA_HEADS=$(compose exec -T postgres \ + psql -U clawith -d clawith -Atc \ + "SELECT version_num FROM alembic_version ORDER BY version_num;" | tr -d '\r' | sort) +if [ "$ACTUAL_SOURCE_SCHEMA_HEADS" != "$EXPECTED_SOURCE_SCHEMA_HEADS" ]; then + echo "升级源 schema head 不匹配" + echo "expected=$EXPECTED_SOURCE_SCHEMA_HEADS" + echo "actual=$ACTUAL_SOURCE_SCHEMA_HEADS" + exit 1 fi +echo "使用升级源镜像建立 LangGraph checkpoint schema" +run_schema_command "$OLD_IMAGE" "python -m app.scripts.setup_langgraph_checkpoints" + docker run -d \ --name "$OLD_CONTAINER" \ --network "$NETWORK" \ @@ -109,6 +184,8 @@ docker run -d \ -e SECRET_KEY=ci-test-secret \ -e JWT_SECRET_KEY=ci-test-jwt-secret \ -e CORS_ORIGINS='["*"]' \ + -e PROCESS_ROLE=api,worker \ + -e INSTANCE_ID="$PROJECT-backend-old" \ "$OLD_IMAGE" >/dev/null wait_healthy "$OLD_CONTAINER"