From 45142f908be7f7912809079b3d0c638f824ec70e Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 22 Oct 2026 18:45:59 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(dev):=20=E6=8A=A5=E5=91=8A=20Preview=20?= =?UTF-8?q?=E9=83=A8=E5=88=86=E4=BA=A4=E4=BB=98=E5=B9=B6=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E4=B8=9A=E5=8A=A1=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 记录候选、迁移前后状态和未执行阶段 - 在独占 PostgreSQL 运行显式业务集并修复准备条件 - 保留既有迁移文件和状态恢复边界 --- .github/actions/preview-delivery/action.yml | 20 ++++ .github/workflows/ci.yml | 18 ++- .github/workflows/preview-deploy.yml | 12 ++ docs/40-deployment/development-environment.md | 8 ++ docs/40-deployment/heroku.md | 12 ++ pyproject.toml | 1 + scripts/automation/preview_delivery.sh | 57 +++++++++- scripts/automation/preview_workflow.sh | 19 +++- scripts/automation/repository_check.sh | 12 +- scripts/tests/test_preview_delivery.py | 98 ++++++++++++++++ .../rss/integration/test_feed_collection.py | 2 + .../test_memos_attachment_v2_migration.py | 105 +++++++++++++++--- tests/test_extension_publication_recovery.py | 2 - 13 files changed, 343 insertions(+), 23 deletions(-) create mode 100644 scripts/tests/test_preview_delivery.py diff --git a/.github/actions/preview-delivery/action.yml b/.github/actions/preview-delivery/action.yml index 27871df7..e8a97a63 100644 --- a/.github/actions/preview-delivery/action.yml +++ b/.github/actions/preview-delivery/action.yml @@ -76,6 +76,7 @@ runs: run: bash "$GITHUB_ACTION_PATH/../../../scripts/automation/preview_delivery.sh" resolve-apps - name: Push and release preview peer transports + id: release shell: bash env: APP_NAME: ${{ steps.heroku.outputs.app_name }} @@ -91,6 +92,7 @@ runs: run: bash "$GITHUB_ACTION_PATH/../../../scripts/automation/preview_delivery.sh" release - name: Converge preview Peer advertisement + id: advertisement shell: bash env: DATABASE_URL: ${{ steps.database.outputs.runtime_database_url }} @@ -100,6 +102,7 @@ runs: run: bash "$GITHUB_ACTION_PATH/../../../scripts/automation/preview_delivery.sh" advertise - name: Probe deployed preview + id: probe shell: bash env: APP_NAME: ${{ steps.heroku.outputs.app_name }} @@ -109,3 +112,20 @@ runs: POSTGREST_URL: ${{ steps.heroku.outputs.postgrest_url }} WEB_URL: ${{ steps.heroku.outputs.web_url }} run: bash "$GITHUB_ACTION_PATH/../../../scripts/automation/preview_delivery.sh" probe + + - name: Report runtime stages even after failure + if: ${{ always() }} + shell: bash + env: + HEAD_SHA: ${{ inputs.head_sha }} + PR_NUMBER: ${{ inputs.pr_number }} + HEROKU_API_KEY: ${{ inputs.heroku_api_key }} + EXTENSION_REGISTRY_URL: ${{ inputs.extension_registry_url }} + BRANCH_ID: ${{ steps.neon.outputs.branch_id }} + DATABASE_RESULT: ${{ steps.database.outcome }} + DATABASE_BEFORE: ${{ steps.database.outputs.database_before }} + DATABASE_AFTER: ${{ steps.database.outputs.database_after }} + RELEASE_RESULT: ${{ steps.release.outcome }} + ADVERTISEMENT_RESULT: ${{ steps.advertisement.outcome }} + PROBE_RESULT: ${{ steps.probe.outcome }} + run: bash "$GITHUB_ACTION_PATH/../../../scripts/automation/preview_delivery.sh" summarize diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dab432a..12cbab67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,8 +75,22 @@ jobs: - name: Run repository contract run: pdm run check - - name: Verify native documentation publication - run: pdm run python -m pytest -q scripts/tests/test_extension_documentation.py + - name: Initialize disposable business-test database + env: + INKCRE_ENV_FILE: "" + MIGRATION_DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/inkcre + CORE_DATABASE_PASSWORD: ci-core-test-password-at-least-32-bytes + POSTGREST_DATABASE_PASSWORD: ci-postgrest-test-password-at-least-32-bytes + run: pdm run db:init --profile development + + - name: Run required PostgreSQL business scenarios + env: + INKCRE_TEST_DATABASE_URL: postgresql+psycopg://inkcre_core:ci-core-test-password-at-least-32-bytes@127.0.0.1:5432/inkcre + MIGRATION_DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/inkcre + run: pdm run test:database + + - name: Verify documentation publication and partial Preview reporting + run: pdm run python -m pytest -q scripts/tests/test_extension_documentation.py scripts/tests/test_preview_delivery.py - name: Build and verify all first-party wheels env: diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 7b380599..2490b883 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -26,6 +26,7 @@ jobs: environment: preview steps: - name: Checkout trusted delivery implementation + id: controller uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: ref: ${{ github.workflow_sha }} @@ -80,6 +81,7 @@ jobs: pr_number: ${{ github.event.pull_request.number }} - name: Deploy exact-head sibling Registry to isolated Pages branch + id: registry uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -102,6 +104,7 @@ jobs: pr_number: ${{ github.event.pull_request.number }} - name: Deliver preview + id: delivery uses: ./.delivery/.github/actions/preview-delivery with: core_database_password: ${{ secrets.CORE_DATABASE_PASSWORD }} @@ -115,6 +118,15 @@ jobs: jwt_secret: ${{ secrets.JWT_SECRET }} pr_number: ${{ github.event.pull_request.number }} + - name: Report complete or partial candidate delivery + if: ${{ always() && steps.controller.outcome == 'success' }} + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + REGISTRY_RESULT: ${{ steps.registry.outcome }} + REGISTRY_SNAPSHOT: ${{ steps.registry.outputs.deployment-url }} + DELIVERY_RESULT: ${{ steps.delivery.outcome }} + run: bash .delivery/scripts/automation/preview_workflow.sh summarize + cleanup: name: Retire preview resources if: >- diff --git a/docs/40-deployment/development-environment.md b/docs/40-deployment/development-environment.md index 641b376a..fda0d527 100644 --- a/docs/40-deployment/development-environment.md +++ b/docs/40-deployment/development-environment.md @@ -153,6 +153,14 @@ Credentialed semantic-quality acceptance is explicit and separate from the ordin selected migrated `INKCRE_TEST_DATABASE_URL`; see the local Semantic Retrieval Unit TDD for the command and pass/fail authority. +`pdm run test:database` 是无需真实 AI/IMAP 服务的 PostgreSQL 业务回归入口,具体场景清单由 +`scripts/automation/repository_check.sh` 拥有。它要求显式 `INKCRE_TEST_DATABASE_URL`;未设置时直接失败, +不会将整组 skip 当作通过。数据库必须已经迁移且可丢弃:场景包含清空数据、事务回滚和迁移往返验证, +不能连接共享开发库、Preview 或生产。Memos 迁移场景还要求该独占 PostgreSQL 的 `MIGRATION_DATABASE_URL` +具有创建数据库权限;它建立随机命名的历史数据库,只往返附件所属 revision,并在结束后删除该数据库。 +这保留实际 migration 编排和应用下载读回,不会回退当前业务测试库或声称验证了整条历史 downgrade 链。 +CI 在自身临时 PostgreSQL service 上先初始化再执行这一组;外部服务和真实凭据验收继续由各自专项入口负责。 + ## Database Branch Workflow In CI The repository currently uses Neon branch automation for pull requests. diff --git a/docs/40-deployment/heroku.md b/docs/40-deployment/heroku.md index b7f6762d..00b5212c 100644 --- a/docs/40-deployment/heroku.md +++ b/docs/40-deployment/heroku.md @@ -77,6 +77,18 @@ reads and consumer acceptance are deliberately independent and do not block Core Repository-qualified app and branch identities keep an impl-py PR and an impl-ts PR with the same number from addressing the same review resources. +交付摘要在失败时也报告目标 SHA、Registry snapshot、数据库初始化、进程 release、Peer 广告和探测的 +各自结果。数据库输出保留初始化前后的实际 migration head 与候选期望 head/contract;没有取得的观察 +明确留空,不推断旧服务已经停止。Core 镜像写入目标 SHA label。Heroku 当前 release 身份另外只读查询, +该身份本身不证明本轮源码已生效。进程 Registry fallback 也不能覆盖 Peer/deployment 的优先配置, +跨仓业务验收仍需核对实际 Distribution 和 Registry 来源。 + +同一 PR 的 Neon 分支保留状态。若候选移除了该分支已经执行的未发布迁移,重复交付不会自动 reset、 +downgrade 或补回迁移。先保留原分支作诊断:需要验证保留状态升级时,修复候选,使它能从当前 head +前进;需要验证全新候选时,将同一明确源码放到新的 PR,使现有分支 owner 从 `preview-base` 创建新的 +隔离分支。原 PR 的关闭清理只在不再需要其数据与证据时执行。这里不增加隐式“自愈”或复用其他 PR +数据库的行为;失败时 Registry 可能已经更新,不表示整个组合已经就绪。 + ## Canonical Production Production is one logical environment containing two peer transports: diff --git a/pyproject.toml b/pyproject.toml index ecfe99cf..d555dd7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ format = "ruff format ." "format:check" = "ruff format --check ." typecheck = "pyrefly check --min-severity=warn --output-format=min-text --progress-bar=no" test = "python -m pytest -q" +"test:database" = "bash scripts/automation/repository_check.sh database-tests" "test:acceptance" = "python -m pytest -q -m acceptance tests/semantic_retrieval/acceptance" "test:organization-acceptance" = "python -m pytest -q -s tests/organization/acceptance/test_black_box.py" "check:lock" = "python scripts/check_lock.py" diff --git a/scripts/automation/preview_delivery.sh b/scripts/automation/preview_delivery.sh index c7a6b218..3a5ee949 100644 --- a/scripts/automation/preview_delivery.sh +++ b/scripts/automation/preview_delivery.sh @@ -110,11 +110,27 @@ case "${1:-}" in converge-database) for name in CORE_DATABASE_PASSWORD HEAD_SHA MIGRATION_DATABASE_URL \ POSTGREST_DATABASE_PASSWORD SOURCE_DATABASE_URL; do require_env "$name"; done + readiness_file="$(mktemp)" + trap 'rm -f "$readiness_file"' EXIT + before_status=0 + docker run --rm --env MIGRATION_DATABASE_URL "inkcre-preview-web:$HEAD_SHA" \ + python scripts/container.py db ready --profile runtime --json > "$readiness_file" || before_status=$? + emit_output database_before "$(jq -c --argjson probe_exit "$before_status" \ + '{probe_exit:$probe_exit,status,contract,migration,database}' "$readiness_file")" + initialization_status=0 docker run --rm --env CORE_DATABASE_PASSWORD --env MIGRATION_DATABASE_URL \ --env POSTGREST_DATABASE_PASSWORD "inkcre-preview-web:$HEAD_SHA" \ - python scripts/container.py db init --profile runtime --environment preview + python scripts/container.py db init --profile runtime --environment preview || initialization_status=$? + readiness_status=0 docker run --rm --env MIGRATION_DATABASE_URL "inkcre-preview-web:$HEAD_SHA" \ - python scripts/container.py db ready --profile runtime --json + python scripts/container.py db ready --profile runtime --json > "$readiness_file" || readiness_status=$? + emit_output database_after "$(jq -c --argjson probe_exit "$readiness_status" \ + '{probe_exit:$probe_exit,status,contract,migration,database}' "$readiness_file")" + if [ "$initialization_status" != 0 ]; then + echo "Preview database initialization failed; preserve this branch for inspection. Choose a forward-compatible candidate or a new PR/isolated branch from preview-base; do not reset automatically." >&2 + exit "$initialization_status" + fi + test "$readiness_status" = 0 runtime_database_url="$(docker run --rm --env SOURCE_DATABASE_URL \ --env TARGET_DATABASE_PASSWORD="$CORE_DATABASE_PASSWORD" \ --env TARGET_DATABASE_ROLE=inkcre_core --entrypoint python \ @@ -233,8 +249,43 @@ EOF heroku logs --num 200 --app "$POSTGREST_APP_NAME" exit 1 ;; + summarize) + require_env HEAD_SHA + require_env PR_NUMBER + [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] + append_summary </dev/null)"; then + observed="$(jq -c '.[0] | {version,status,description}' <<<"$releases")" + printf -- '- 当前 Heroku release `%s`:`%s`(不单独证明源码或探测通过)\n' "$app_name" "$observed" | append_summary + else + printf -- '- 当前 Heroku release `%s`:未取得观察,不能推断服务不存在。\n' "$app_name" | append_summary + fi + done + ;; *) - echo "usage: $0 resolve-neon|normalize-roles|converge-database|resolve-apps|release|advertise|probe" >&2 + echo "usage: $0 resolve-neon|normalize-roles|converge-database|resolve-apps|release|advertise|probe|summarize" >&2 exit 2 ;; esac diff --git a/scripts/automation/preview_workflow.sh b/scripts/automation/preview_workflow.sh index a3491cb9..f6845c1e 100644 --- a/scripts/automation/preview_workflow.sh +++ b/scripts/automation/preview_workflow.sh @@ -23,14 +23,31 @@ case "${1:-}" in require_env HEAD_SHA require_env SOURCE_DIRECTORY docker build --platform linux/amd64 --provenance=false --target heroku-web \ + --build-arg "SOURCE_REVISION=$HEAD_SHA" \ --tag "inkcre-preview-web:$HEAD_SHA" "$SOURCE_DIRECTORY" docker build --platform linux/amd64 --provenance=false --target heroku-release \ + --build-arg "SOURCE_REVISION=$HEAD_SHA" \ --tag "inkcre-preview-release:$HEAD_SHA" "$SOURCE_DIRECTORY" docker build --platform linux/amd64 --provenance=false \ --file "$SOURCE_DIRECTORY/Dockerfile.postgrest" \ --build-arg "SOURCE_REVISION=$HEAD_SHA" \ --tag "inkcre-preview-postgrest:$HEAD_SHA" "$SOURCE_DIRECTORY" ;; + summarize) + require_env HEAD_SHA + append_summary <&2; exit 2 ;; + *) echo "usage: $0 build-extensions|build-images|summarize|validate-identity|cleanup-apps" >&2; exit 2 ;; esac diff --git a/scripts/automation/repository_check.sh b/scripts/automation/repository_check.sh index 4ed36aea..1681af1e 100644 --- a/scripts/automation/repository_check.sh +++ b/scripts/automation/repository_check.sh @@ -3,6 +3,16 @@ source "$(dirname "$0")/_common.sh" case "${1:-}" in + database-tests) + require_env INKCRE_TEST_DATABASE_URL + # This admitted suite needs migrated, disposable PostgreSQL, but no live AI or IMAP service. + pdm run python -m pytest -q -rs \ + tests/agent/integration tests/ai/integration tests/deployment_config/integration \ + tests/info_base/integration tests/lexical_retrieval/integration \ + tests/organization/integration tests/peer/integration tests/semantic_retrieval/integration \ + tests/extensions/memos/integration tests/extensions/rss/integration/test_feed_collection.py \ + tests/migrations/test_memos_attachment_v2_migration.py + ;; release-intent) arguments=() if [[ "${BASE_REVISION:-}" =~ ^[0-9a-f]{40}$ ]] && @@ -25,5 +35,5 @@ case "${1:-}" in --project "extensions/$extension" --wheel "$wheel" done ;; - *) echo "usage: $0 release-intent|extension-wheels" >&2; exit 2 ;; + *) echo "usage: $0 database-tests|release-intent|extension-wheels" >&2; exit 2 ;; esac diff --git a/scripts/tests/test_preview_delivery.py b/scripts/tests/test_preview_delivery.py new file mode 100644 index 00000000..be7ec0c7 --- /dev/null +++ b/scripts/tests/test_preview_delivery.py @@ -0,0 +1,98 @@ +"""Exercise partial delivery reporting without provider credentials or shared state.""" + +import json +import os +from pathlib import Path +import subprocess + + +ROOT = Path(__file__).resolve().parents[2] + + +def _run(script: str, command: str, environment: dict[str, str]): + return subprocess.run( # noqa: S603 - runs a fixed repository script with isolated provider shims. + ["bash", str(ROOT / "scripts/automation" / script), command], + env=environment, + capture_output=True, + text=True, + check=False, + ) + + +def test_failed_migration_keeps_observed_heads_and_reports_partial_delivery(tmp_path): + binaries = tmp_path / "bin" + binaries.mkdir() + observed = { + "status": "error", + "contract": {"revision": "candidate-contract"}, + "migration": {"current": ["removed-revision"], "expected": ["candidate-head"]}, + "database": {"status": "error"}, + } + docker = binaries / "docker" + docker.write_text( + "#!/bin/sh\n" + 'case "$*" in\n' + f" *' db ready '*) printf '%s\\n' '{json.dumps(observed)}' ;;\n" + " *' db init '*) echo 'missing revision' >&2; exit 17 ;;\n" + " *) echo 'unexpected provider operation' >&2; exit 88 ;;\n" + "esac\n" + ) + docker.chmod(0o700) + heroku = binaries / "heroku" + releases = json.dumps( + [{"version": 7, "status": "succeeded", "description": "Previous web"}] + ) + heroku.write_text(f"#!/bin/sh\nprintf '%s\\n' '{releases}'\n") + heroku.chmod(0o700) + output = tmp_path / "outputs" + environment = { + **os.environ, + "PATH": f"{binaries}:{os.environ['PATH']}", + "GITHUB_OUTPUT": str(output), + "GITHUB_STEP_SUMMARY": str(tmp_path / "summary"), + "HEAD_SHA": "a" * 40, + "PR_NUMBER": "123", + "CORE_DATABASE_PASSWORD": "secret-not-for-report", + "POSTGREST_DATABASE_PASSWORD": "secret-not-for-report", + "MIGRATION_DATABASE_URL": "secret-not-for-report", + "SOURCE_DATABASE_URL": "secret-not-for-report", + } + failed = _run("preview_delivery.sh", "converge-database", environment) + assert failed.returncode == 17 + values = dict(line.split("=", 1) for line in output.read_text().splitlines()) + assert json.loads(values["database_before"])["migration"] == observed["migration"] + assert json.loads(values["database_after"])["migration"] == observed["migration"] + assert "runtime_database_url" not in values + + runtime_report = _run( + "preview_delivery.sh", + "summarize", + { + **environment, + "DATABASE_BEFORE": values["database_before"], + "DATABASE_AFTER": values["database_after"], + "DATABASE_RESULT": "failure", + "RELEASE_RESULT": "skipped", + "ADVERTISEMENT_RESULT": "skipped", + "PROBE_RESULT": "skipped", + }, + ) + assert runtime_report.returncode == 0, runtime_report.stderr + candidate_report = _run( + "preview_workflow.sh", + "summarize", + { + **environment, + "REGISTRY_RESULT": "success", + "REGISTRY_SNAPSHOT": "https://snapshot.pages.dev", + "DELIVERY_RESULT": "failure", + }, + ) + assert candidate_report.returncode == 0, candidate_report.stderr + summary = (tmp_path / "summary").read_text() + assert "数据库初始化:`failure`" in summary + assert "进程 release:`skipped`" in summary + assert "removed-revision" in summary + assert '"version":7' in summary + assert "https://snapshot.pages.dev" in summary + assert "secret-not-for-report" not in summary + failed.stdout + failed.stderr diff --git a/tests/extensions/rss/integration/test_feed_collection.py b/tests/extensions/rss/integration/test_feed_collection.py index 1be16cbf..423941e6 100644 --- a/tests/extensions/rss/integration/test_feed_collection.py +++ b/tests/extensions/rss/integration/test_feed_collection.py @@ -655,6 +655,7 @@ async def _exercise_atom_and_failures() -> None: def test_rss_collection_reconciliation_watermark_conditional_and_manual_enclosure( async_runner, ): + JobManager.start() register_core_resolvers() Extension._init_resolvers() Extension._init_sources() @@ -667,6 +668,7 @@ def test_rss_collection_reconciliation_watermark_conditional_and_manual_enclosur def test_atom_family_failure_and_automatic_enclosure_materialization( async_runner, ): + JobManager.start() register_core_resolvers() Extension._init_resolvers() Extension._init_sources() diff --git a/tests/migrations/test_memos_attachment_v2_migration.py b/tests/migrations/test_memos_attachment_v2_migration.py index 90461277..d1a2bb32 100644 --- a/tests/migrations/test_memos_attachment_v2_migration.py +++ b/tests/migrations/test_memos_attachment_v2_migration.py @@ -1,4 +1,4 @@ -"""Online data-preservation proof for the Memos attachment v2 migration.""" +"""Memos migration round trips in their own historical PostgreSQL database.""" from tests.database import read_block, read_relations @@ -12,10 +12,15 @@ from alembic import command from alembic.config import Config +import psycopg +from psycopg import sql import pytest import sqlalchemy import sqlmodel +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool +from app.database_contract.connection import psycopg_database_url from app.business.info_base.storage.postgresql import PostgreSQLBlobPointer from tests.database import TestSession from app.schemas.info_base.block import BlockModel @@ -33,13 +38,53 @@ PROJECT_ROOT = Path(__file__).parents[2] V1 = "extensions.memos.attachment.v1" V2 = "extensions.memos.attachment.v2" +ATTACHMENT_REVISION = "e1f4a5b6c7d8" PREVIOUS_REVISION = "d0e3f4a5b6c7" -def _alembic() -> Config: +@pytest.fixture +def attachment_database(monkeypatch): + """Own a legacy schema instead of downgrading the shared current-contract fixture.""" + owner_url = os.environ["MIGRATION_DATABASE_URL"] + database = f"memos_migration_{uuid.uuid4().hex}" + database_url = ( + sqlalchemy.engine.make_url(owner_url) + .set(database=database) + .render_as_string(hide_password=False) + ) + with psycopg.connect(psycopg_database_url(owner_url), autocommit=True) as owner: + owner.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database))) + engine = sqlalchemy.create_engine(database_url, poolclass=NullPool) + async_engine = create_async_engine(database_url, poolclass=NullPool) config = Config(PROJECT_ROOT / "alembic.ini") config.set_main_option("script_location", str(PROJECT_ROOT / "migrations")) - return config + try: + monkeypatch.setenv("MIGRATION_DATABASE_URL", database_url) + monkeypatch.setattr("tests.database.TEST_ENGINE", engine) + monkeypatch.setattr( + "app.persistence.info_base.uow.AsyncSessionFactory", + async_sessionmaker(async_engine, expire_on_commit=False), + ) + command.upgrade(config, ATTACHMENT_REVISION) + with engine.begin() as connection: + connection.execute( + sqlalchemy.text( + "INSERT INTO inkcre.storage_types (id, description, config_schema) " + "VALUES ('postgresql_binary', 'Migration fixture', '{}'::jsonb)" + ) + ) + connection.execute( + sqlalchemy.text( + "INSERT INTO inkcre.storages (id, type, config) " + "VALUES (-4, 'postgresql_binary', '{}'::jsonb)" + ) + ) + yield config + finally: + engine.dispose() + asyncio.run(async_engine.dispose()) + with psycopg.connect(psycopg_database_url(owner_url), autocommit=True) as owner: + owner.execute(sql.SQL("DROP DATABASE {}").format(sql.Identifier(database))) def _cleanup(block_ids: set[int], blob_ids: set[uuid.UUID]) -> None: @@ -62,8 +107,10 @@ def _cleanup(block_ids: set[int], blob_ids: set[uuid.UUID]) -> None: db_session.commit() -def test_seeded_v1_upgrade_and_downgrade_preserve_identity_blob_and_owner_slot(): - config = _alembic() +def test_seeded_v1_upgrade_and_downgrade_preserve_identity_blob_and_owner_slot( + attachment_database, +): + config = attachment_database block_ids: set[int] = set() blob_ids: set[uuid.UUID] = set() command.downgrade(config, PREVIOUS_REVISION) @@ -104,7 +151,7 @@ def test_seeded_v1_upgrade_and_downgrade_preserve_identity_blob_and_owner_slot() memo_id = memo.id blob_id = blob.id - command.upgrade(config, "head") + command.upgrade(config, ATTACHMENT_REVISION) Extension._init_resolvers() with TestSession() as db_session: metadata = read_block(attachment_id, db_session) @@ -170,11 +217,10 @@ def test_seeded_v1_upgrade_and_downgrade_preserve_identity_blob_and_owner_slot() assert [(item.from_, item.to_) for item in owner] == [(memo_id, attachment_id)] finally: _cleanup(block_ids, blob_ids) - command.upgrade(config, "head") -def test_downgrade_refuses_shared_semantic_content_before_mutation(): - config = _alembic() +def test_failed_downgrade_preserves_shared_semantic_content(attachment_database): + config = attachment_database block_ids: set[int] = set() blob_ids: set[uuid.UUID] = set() @@ -210,14 +256,45 @@ def test_downgrade_refuses_shared_semantic_content_before_mutation(): metadata_ids = tuple(item.id for item in metadata if item.id is not None) block_ids.update((semantic.id, *metadata_ids)) + with TestSession() as db_session: + original_blocks = [] + for block_id in (semantic.id, *metadata_ids): + block = db_session.get(BlockModel, block_id) + assert block is not None + original_blocks.append(block.model_dump()) + original_relations = [ + relation.model_dump() + for relation in db_session.exec( + sqlmodel.select(RelationModel) + .where( + RelationModel.from_.in_(metadata_ids) # pyrefly: ignore[missing-attribute] + ) + .order_by(sqlmodel.col(RelationModel.id)) + ).all() + ] + with pytest.raises(RuntimeError, match="post-upgrade information"): command.downgrade(config, PREVIOUS_REVISION) with TestSession() as db_session: - assert all( - db_session.get(BlockModel, item_id) is not None for item_id in metadata_ids - ) - assert db_session.get(BlockModel, semantic.id) is not None + for block_id, original in zip( + (semantic.id, *metadata_ids), original_blocks, strict=True + ): + block = db_session.get(BlockModel, block_id) + assert block is not None + assert block.model_dump() == original + assert [ + relation.model_dump() + for relation in db_session.exec( + sqlmodel.select(RelationModel) + .where( + RelationModel.from_.in_(metadata_ids) # pyrefly: ignore[missing-attribute] + ) + .order_by(sqlmodel.col(RelationModel.id)) + ).all() + ] == original_relations + persisted_blob = db_session.get(StorageBlobModel, blob.id) + assert persisted_blob is not None + assert persisted_blob.data == b"shared" finally: _cleanup(block_ids, blob_ids) - command.upgrade(config, "head") diff --git a/tests/test_extension_publication_recovery.py b/tests/test_extension_publication_recovery.py index aab577fa..15d5d363 100644 --- a/tests/test_extension_publication_recovery.py +++ b/tests/test_extension_publication_recovery.py @@ -32,8 +32,6 @@ def test_full_rerun_fails_capture_and_publish_only_rerun_restores_saved_artifact # GitHub evaluates the step condition for a full rerun (attempt 2), then runs # this exact guard. Check its observable failure, not just the YAML wiring. - rerun_attempt = 2 - assert rerun_attempt != 1 result = subprocess.run( # noqa: S603 - executes the checked-in workflow guard body. ["bash", "-e", "-c", script], capture_output=True, text=True, check=False ) From b7ffc8aeebaebc3b75e4b692b409ff7eb5722718 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 22 Oct 2026 18:59:19 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(tests):=20=E5=9C=A8=E5=BB=BA=E5=BA=93?= =?UTF-8?q?=E5=89=8D=E8=A7=84=E8=8C=83=E5=8C=96=E5=8E=86=E5=8F=B2=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 普通 PostgreSQL URL 使用既有 psycopg 规范化,避免建库后构造 engine 失败 --- tests/migrations/test_memos_attachment_v2_migration.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/migrations/test_memos_attachment_v2_migration.py b/tests/migrations/test_memos_attachment_v2_migration.py index d1a2bb32..cf27081e 100644 --- a/tests/migrations/test_memos_attachment_v2_migration.py +++ b/tests/migrations/test_memos_attachment_v2_migration.py @@ -20,7 +20,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool -from app.database_contract.connection import psycopg_database_url +from app.database_contract.connection import normalized_database_url, psycopg_database_url from app.business.info_base.storage.postgresql import PostgreSQLBlobPointer from tests.database import TestSession from app.schemas.info_base.block import BlockModel @@ -45,19 +45,19 @@ @pytest.fixture def attachment_database(monkeypatch): """Own a legacy schema instead of downgrading the shared current-contract fixture.""" - owner_url = os.environ["MIGRATION_DATABASE_URL"] + owner_url = normalized_database_url(os.environ["MIGRATION_DATABASE_URL"]) database = f"memos_migration_{uuid.uuid4().hex}" database_url = ( sqlalchemy.engine.make_url(owner_url) .set(database=database) .render_as_string(hide_password=False) ) - with psycopg.connect(psycopg_database_url(owner_url), autocommit=True) as owner: - owner.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database))) engine = sqlalchemy.create_engine(database_url, poolclass=NullPool) async_engine = create_async_engine(database_url, poolclass=NullPool) config = Config(PROJECT_ROOT / "alembic.ini") config.set_main_option("script_location", str(PROJECT_ROOT / "migrations")) + with psycopg.connect(psycopg_database_url(owner_url), autocommit=True) as owner: + owner.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database))) try: monkeypatch.setenv("MIGRATION_DATABASE_URL", database_url) monkeypatch.setattr("tests.database.TEST_ENGINE", engine)