From d7f2d897e14e50243a7e2596b0223539edd74816 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Wed, 21 Oct 2026 23:05:29 +0800 Subject: [PATCH 01/11] ci(release): gate database upgrade support on installed artifacts --- .github/workflows/master.yml | 36 ++ .github/workflows/release.yml | 17 +- Makefile | 15 +- docs/en/development/releasing.md | 34 ++ docs/en/docs/get-started/install-and-run.md | 38 ++- docs/zh/development/releasing.md | 28 ++ docs/zh/docs/get-started/install-and-run.md | 31 +- release/upgrade-support.json | 10 + scripts/ci_legacy_upgrade_rejection.py | 203 ++++++++++++ scripts/release_version.py | 5 +- scripts/run_upgrade_release_gate.py | 54 +++ scripts/upgrade_release_gate.py | 349 ++++++++++++++++++++ tests/release_upgrades/README.md | 79 +++++ tests/test_release_version.py | 11 +- tests/test_upgrade_release_gate.py | 210 ++++++++++++ 15 files changed, 1076 insertions(+), 44 deletions(-) create mode 100644 release/upgrade-support.json create mode 100644 scripts/ci_legacy_upgrade_rejection.py create mode 100644 scripts/run_upgrade_release_gate.py create mode 100644 scripts/upgrade_release_gate.py create mode 100644 tests/release_upgrades/README.md create mode 100644 tests/test_upgrade_release_gate.py diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 01ff65b202..d9c4d2ad9c 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -19,6 +19,42 @@ jobs: changes: uses: ./.github/workflows/ci-changes.yml + upgrade-release-gate: + name: Upgrade release gate + runs-on: ubuntu-24.04 + timeout-minutes: 90 + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + - name: Set up the environment + uses: ./.github/actions/setup-python-env + - name: Verify upgrade policy and gate regressions + run: | + make upgrade-policy-check + uv run pytest -q tests/test_upgrade_release_gate.py + - name: Build the target artifact + run: uv build --wheel --out-dir dist + - name: Run declared installed-release acceptance + run: >- + uv run python scripts/run_upgrade_release_gate.py + --output "$RUNNER_TEMP/upgrade-release-evidence" + - name: Reproduce unsupported full 1.3.0 database upgrade + run: | + wheel=$(find dist -name '*.whl' -print -quit) + uv run python scripts/ci_legacy_upgrade_rejection.py \ + --wheel "$wheel" --output "$RUNNER_TEMP/upgrade-release-evidence/legacy-rejection" + - name: Preserve upgrade acceptance evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: upgrade-release-evidence-${{ github.sha }} + path: ${{ runner.temp }}/upgrade-release-evidence/ + if-no-files-found: error + retention-days: 30 + quality: timeout-minutes: 20 runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dff86dbc30..d21ccd47ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ concurrency: jobs: release-build: runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 90 steps: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -74,6 +74,21 @@ jobs: uv build --out-dir dist uvx --from twine twine check dist/* + - name: Gate upgrade claims on installed release acceptance + run: >- + uv run python scripts/run_upgrade_release_gate.py + --release --release-event "$GITHUB_EVENT_PATH" + --output "$RUNNER_TEMP/upgrade-release-evidence" + + - name: Preserve upgrade acceptance evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: upgrade-release-evidence + path: ${{ runner.temp }}/upgrade-release-evidence/ + if-no-files-found: error + retention-days: 30 + - name: Upload release distributions uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: diff --git a/Makefile b/Makefile index 60b09a699b..6f968cea9d 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ notebooks-test: ## Execute provider-free tutorials in fresh kernels; use ARGS fo @uv run --locked --group notebooks python examples/jupyter/run.py $(ARGS) .PHONY: check -check: version-check workflow-actions-check integration-manifest-check integration-guidance-check ## Run code quality tools. +check: version-check upgrade-policy-check workflow-actions-check integration-manifest-check integration-guidance-check ## Run code quality tools. .PHONY: integration-guidance integration-guidance-check integration-guidance: ## Refresh the managed regions of packaged integration guidance. @@ -235,16 +235,25 @@ version-check: ## Check release references and generated API code; optionally as @$(MAKE) api-generate-check js-api-generate-check .PHONY: release-check -release-check: version-check docs-install ## Check version references and require matching bilingual notes for a stable release. +release-check: version-check upgrade-policy-check docs-install ## Check version references and require matching bilingual notes for a stable release. @cd website && node --import tsx --test tests/releases.test.ts +.PHONY: upgrade-policy-check +upgrade-policy-check: ## Verify the declared upgrade matrix, persistence review, and installation guidance. + @uv run python scripts/upgrade_release_gate.py + +.PHONY: upgrade-release-test +upgrade-release-test: ## Run installed-release acceptance for the single wheel in dist (fresh-install-only makes no upgrade claim). + @uv run python scripts/run_upgrade_release_gate.py + .PHONY: clean-build clean-build: ## Clean build artifacts @echo "🚀 Removing build artifacts" @uv run python -c "import shutil; import os; shutil.rmtree('dist') if os.path.exists('dist') else None" .PHONY: publish -publish: ## Publish a release to PyPI. +publish: ## Publish after release upgrade acceptance; UPGRADE_RELEASE_NOTES must name the reviewed release notes file. + @uv run python scripts/run_upgrade_release_gate.py --release --release-notes "$$UPGRADE_RELEASE_NOTES" @echo "🚀 Publishing." @uv publish dist/* diff --git a/docs/en/development/releasing.md b/docs/en/development/releasing.md index 0300298b75..e95ae9c0b6 100644 --- a/docs/en/development/releasing.md +++ b/docs/en/development/releasing.md @@ -56,6 +56,7 @@ the website lists stable releases only. `make docs-test` also runs this check as | Source | Managed value | | --- | --- | | `openapi/powercontext.yaml` | `info.version`, the prepared release version. | +| `release/upgrade-support.json` | Target release version; source versions and the support policy require compatibility review. | | `src/powercontext/http/_generated/` | `API_VERSION` in `operations.py` and `info.version` in `schema.py`, regenerated through `make api-generate`. | | `README.md`, `README_CN.md`, `README_JP.md` | Current release prose, pinned package installation commands, and matching Agent integration tag. | | `docs/en/docs/get-started/quickstart.md`, `docs/zh/docs/get-started/quickstart.md` | Current release and matching installation/tag instructions. | @@ -126,6 +127,39 @@ installation pins are synchronized. ## Verify before publication +### Database upgrade support + +`release/upgrade-support.json` declares the target version and every supported source version, backend, and +configuration. The current policy is `fresh-install-only`; existing Server database upgrades are unsupported. +Do not advertise a supported upgrade until its installed-release acceptance suite is implemented and passes. +The suite contract and evidence requirements are in `tests/release_upgrades/README.md` in the repository. + +`make check` and `make release-check` validate this policy and the bilingual installation notices. Changes to +managed persistence inputs require a compatibility review with an updated digest and rationale. A digest is a +review trigger, not evidence that a migration is correct. `make version-bump` also updates the policy target; +regenerate the support notices with `uv run python scripts/upgrade_release_gate.py --write-docs` after matrix changes. + +After building exactly one wheel into `dist/`, execute the declared upgrade cases: + +```bash +make upgrade-release-test +uv run python scripts/run_upgrade_release_gate.py --release --release-notes /path/to/release-notes.md +``` + +The release check requires the actual wheel version to match the policy and the release notes to include the exact +English support declaration from the installation guide. It installs each exact source release and the target wheel +in separate environments. Missing tests, skipped backends, failures, and missing evidence block the check. A passing +`fresh-install-only` policy check does not establish an upgrade path. Ordinary PR builds may have a development +version; the `--release` check must run against the final tagged wheel without overriding its version. + +The **Upgrade release gate** CI job also reproduces the known unsupported upgrade rejections using a real 1.3.0 +SQLite Server database. Replace that negative fixture with supported-path acceptance when implementing the full +migration chain. Repository administrators must make this job required in branch protection. +The release workflow gates distribution upload, PyPI publication, and release assets on the acceptance result and +archives its evidence. It runs after `release.published`, so it cannot prevent the earlier manual publication of +the GitHub Release page: run the command above before that action. `make publish` requires +`UPGRADE_RELEASE_NOTES=/path/to/release-notes.md` and runs the same gate before uploading packages. + Run the checks for the complete release changes, including generated contracts and documentation: ```bash diff --git a/docs/en/docs/get-started/install-and-run.md b/docs/en/docs/get-started/install-and-run.md index 2a06f4215a..f6021a91bd 100644 --- a/docs/en/docs/get-started/install-and-run.md +++ b/docs/en/docs/get-started/install-and-run.md @@ -174,17 +174,18 @@ For a long-running process, Docker, authentication, or remote access, continue w ## Update or replace an installation -Before upgrading an existing deployment, back up its database and configuration. Version 1.1.0 upgrades legacy -tag-table constraints during Server startup to support Topic Memory tags. Stop the old Server instances and start -one upgraded instance first so the schema upgrade completes before other instances connect. Databases from before -1.0.0 also need the [Artifact processing migration](../operate/artifact-processing-migration.md) if it has not already -been completed. Existing collection-based Memory databases also require the [offline Atomic Memory migration](../operate/atomic-memory-migration.md) -for existing collection databases. Complete any required Artifact processing migration first, stop all writers, then -apply and verify the Atomic Memory migration. Start the new service only after verification returns `ready: true`. -Upgrade the Server, clients, and Agent integrations together. -The Dashboard must be explicitly enabled; static Bearer authentication is optional for local use. See -[Deploy the Server](../operate/deploy-server.md). Remote plaintext HTTP connections require explicit client consent; -see [Connect to a remote Server](../operate/connect-remote-server.md). + +This release supports fresh installations only; upgrades of existing Server databases are not supported. + + +Keep existing deployments on their working version until their source version and backend have an accepted upgrade +path. To evaluate a fresh installation, use a separate program environment, database location, and port. Replacing +the installed package does not migrate its database. Backups do not make an unsupported migration supported. + +The support declaration is checked against the release matrix and installed-artifact acceptance; see +[Preparing a release](../../development/releasing.md). Domain-specific +[Processing](../operate/artifact-processing-migration.md) and +[Atomic Memory](../operate/atomic-memory-migration.md) maintenance guides do not establish full Server readiness. Review the [Dream coordinated upgrade requirements](../workflows/review-dream-revisions.md#coordinated-upgrade) before upgrading an existing database. The unified Candidate routes replace `/v1/artifact-candidates/*` with @@ -193,21 +194,22 @@ migration after unfinished runs have drained. The current unified migration bund it does not provide a complete production Server upgrade path. Atomic Memory verification alone does not establish that readiness. Do not bypass `migration_required` or `migration_framework_not_ready`. -To upgrade to 1.4.0: +To install 1.4.0 in a separate environment for a fresh deployment: ```bash -uv tool install --force "powercontext[cli,server]==1.4.0" +uv venv /path/to/powercontext-preview +uv pip install --python /path/to/powercontext-preview/bin/python "powercontext[cli,server]==1.4.0" ``` -To replace the installed tool with another Git ref: +To install another Git ref in the separate environment: ```bash -uv tool install --force "powercontext[cli,server] @ git+https://github.com/oceanbase/powercontext.git@" +uv pip install --python /path/to/powercontext-preview/bin/python "powercontext[cli,server] @ git+https://github.com/oceanbase/powercontext.git@" ``` -Update each installed host using its [integration guide](../integrations/index.md) and the same ref. Restart the Server and open a new host session -after updating. Existing SQLite data remains in the user data directory unless `POWERCONTEXT_HOME` or the database URL -changes. +Use the matching ref for clients and Agent integrations connected to the fresh deployment. Preserve the original +deployment's database location and configuration; do not point the new Server at it as an installation check. +On Windows, the virtual environment interpreter is at `Scripts/python.exe`. ## Install a Python role diff --git a/docs/zh/development/releasing.md b/docs/zh/development/releasing.md index f53da6fb39..bfc18fc73c 100644 --- a/docs/zh/development/releasing.md +++ b/docs/zh/development/releasing.md @@ -53,6 +53,7 @@ beta 和 RC 跳过网站条目检查。`make docs-test` 也会通过网站测试 | 来源 | 自动管理的内容 | | --- | --- | | `openapi/powercontext.yaml` | `info.version`,作为待发布版本。 | +| `release/upgrade-support.json` | 目标发布版本;来源版本和支持策略需要单独进行兼容性审查。 | | `src/powercontext/http/_generated/` | `operations.py` 的 `API_VERSION` 和 `schema.py` 的 `info.version`,通过 `make api-generate` 重新生成。 | | `README.md`、`README_CN.md`、`README_JP.md` | 当前版本介绍、固定包版本的安装命令及配套 Agent 集成 tag。 | | `docs/en/docs/get-started/quickstart.md`、`docs/zh/docs/get-started/quickstart.md` | 当前版本介绍及匹配的安装命令、tag。 | @@ -119,6 +120,33 @@ DSH README 中说明某项能力已包含在 `1.1.0` 的文字同样属于历史 ## 发布前验证 +### 数据库升级支持范围 + +`release/upgrade-support.json` 声明目标版本,以及每个受支持的来源版本、后端和配置组合。当前策略为 +`fresh-install-only`,不支持已有 Server 数据库升级。只有实现并通过已安装发行包的完整升级验收,才能声明支持 +对应组合。验收契约和证据要求见仓库中的 `tests/release_upgrades/README.md`。 + +`make check` 和 `make release-check` 会核对策略及双语安装说明。受管持久化文件变化时,必须审查兼容性影响, +更新摘要和理由。摘要用于触发审查,本身不证明迁移正确。`make version-bump` 同时更新策略中的目标版本; +支持矩阵变化后,运行 `uv run python scripts/upgrade_release_gate.py --write-docs` 更新安装说明中的支持范围。 + +将唯一的目标 wheel 构建到 `dist/` 后,执行声明的升级组合验收: + +```bash +make upgrade-release-test +uv run python scripts/run_upgrade_release_gate.py --release --release-notes /path/to/release-notes.md +``` + +发布检查要求实际 wheel 版本与策略一致,发布说明包含安装指南中的完整英文支持声明。每个组合分别安装准确的 +旧版发行包和目标 wheel。测试缺失、后端跳过、执行失败或证据缺失都会阻断检查。`fresh-install-only` 策略检查 +通过不表示存在升级路径。普通 PR 构建允许使用开发版本;`--release` 必须验证最终 tag 构建的 wheel,不能覆盖版本号。 + +CI 中的 **Upgrade release gate** 还会使用真实 1.3.0 SQLite Server 数据库复现已知的不支持升级拒绝行为。 +完整迁移链实现后,应将这一负向场景替换为支持路径的验收。仓库管理员需要在分支保护中将该检查设为必需。 +发布工作流只有验收通过才会上传发行包、发布 PyPI 和上传 Release 附件,并归档验收证据。工作流在 +`release.published` 后运行,无法阻止之前手动发布 GitHub Release 页面,因此必须先运行上述命令再发布页面。 +`make publish` 要求提供 `UPGRADE_RELEASE_NOTES=/path/to/release-notes.md`,并在上传软件包之前执行相同门禁。 + 针对本次发布的完整变化运行检查,包括生成的契约和文档: ```bash diff --git a/docs/zh/docs/get-started/install-and-run.md b/docs/zh/docs/get-started/install-and-run.md index 1af6e476ab..5e8360b924 100644 --- a/docs/zh/docs/get-started/install-and-run.md +++ b/docs/zh/docs/get-started/install-and-run.md @@ -160,14 +160,16 @@ Agent 诊断见[各自的集成文档](../integrations/index.md);Server 状态 ## 更新或替换安装 -升级已有部署前,先备份数据库和配置。1.1.0 会在 Server 启动时升级旧标签表约束,以支持 Topic Memory 标签。 -先停止旧 Server 实例,再启动一个升级后的实例,待结构升级完成后再启动其他实例。对于 1.0.0 之前的数据库, -如果尚未完成[Artifact 处理迁移](../operate/artifact-processing-migration.md),还需要先执行该迁移。 -升级到 1.4.0 时,已有集合式 Memory 数据库还需完成[Atomic Memory 离线迁移](../operate/atomic-memory-migration.md)。 -先完成所需的 Artifact 处理迁移,再停止所有写入进程,执行 Atomic Memory 迁移并验证 `ready: true` 后启动新服务。 -Server、客户端和 Agent 集成需一起升级。Dashboard 需要显式启用,本地使用可选择静态 Bearer 认证, -见[部署 Server](../operate/deploy-server.md);远程明文 HTTP 连接需要客户端明确同意, -见[连接远程 Server](../operate/connect-remote-server.md)。 + +此版本仅支持新建部署,尚不支持已有 Server 数据库升级。 + + +已有部署应保持当前可用版本,等待来源版本及后端具备已验收的升级路径。试用新建部署时,请使用独立程序环境、 +数据库位置和端口。替换程序包不会完成数据库迁移;备份也不能使不受支持的迁移变为可用。 + +支持声明由发布矩阵和安装包验收共同检查,详见[准备发布](../../development/releasing.md)。 +[Artifact 处理迁移](../operate/artifact-processing-migration.md)和 +[Atomic Memory 迁移](../operate/atomic-memory-migration.md)各自的验证结果不代表完整 Server 就绪。 升级已有数据库前,请先检查 [Dream 协调升级要求](../workflows/review-dream-revisions.md#协调升级)。 统一 Candidate 路由将 `/v1/artifact-candidates/*` 替换为 `/v1/candidates/*`,不提供别名,Dream 客户端要求契约 2。 @@ -175,20 +177,21 @@ Server、客户端和 Agent 集成需一起升级。Dashboard 需要显式启用 Server 升级路径;Atomic Memory 验证通过也不代表整体迁移就绪。不要绕过 `migration_required` 或 `migration_framework_not_ready`。 -升级到 1.4.0: +安装 1.4.0 到独立环境以试用新建部署: ```bash -uv tool install --force "powercontext[cli,server]==1.4.0" +uv venv /path/to/powercontext-preview +uv pip install --python /path/to/powercontext-preview/bin/python "powercontext[cli,server]==1.4.0" ``` -使用其他 Git ref 替换现有工具: +在独立环境安装其他 Git ref: ```bash -uv tool install --force "powercontext[cli,server] @ git+https://github.com/oceanbase/powercontext.git@" +uv pip install --python /path/to/powercontext-preview/bin/python "powercontext[cli,server] @ git+https://github.com/oceanbase/powercontext.git@" ``` -按[各自的集成文档](../integrations/index.md)更新已安装宿主,并使用同一个 ref。更新后重启 Server,再开启新的宿主会话。只要没有修改 -`POWERCONTEXT_HOME` 或数据库 URL,现有 SQLite 数据会继续保留。 +连接新建部署的客户端及 Agent 集成应使用匹配的 ref。保留原部署的数据库位置和配置,不要用原数据库测试新版启动。 +Windows 的虚拟环境解释器位于 `Scripts/python.exe`。 ## 为 Python 项目安装角色 diff --git a/release/upgrade-support.json b/release/upgrade-support.json new file mode 100644 index 0000000000..a1a0dff078 --- /dev/null +++ b/release/upgrade-support.json @@ -0,0 +1,10 @@ +{ + "format": 1, + "target_version": "1.4.0", + "mode": "fresh-install-only", + "schema_review": { + "persistence_sha256": "ca3b475f2d0c883e19291a8d8b1dadbe50f93ebd93aad550a9112a3497737927", + "rationale": "The full Server upgrade framework is incomplete (#2154). Existing databases are unsupported; no production upgrade combination is advertised until installed-release acceptance passes." + }, + "cases": [] +} diff --git a/scripts/ci_legacy_upgrade_rejection.py b/scripts/ci_legacy_upgrade_rejection.py new file mode 100644 index 0000000000..cefdf44def --- /dev/null +++ b/scripts/ci_legacy_upgrade_rejection.py @@ -0,0 +1,203 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Reproduce unsupported 1.3.0 upgrade rejection on an actual old SQLite Server. + +This is a negative regression, never positive upgrade acceptance. Once the path +is supported, replace this fixture with the full matrix suite before advertising it. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sqlite3 +import subprocess +import tempfile +from pathlib import Path + +import httpx +from ci_release_smoke import _available_port, _stop_process, _wait_until_ready +from upgrade_release_gate import installed, require, run, sha256, wheel_version + + +def console(python: Path) -> Path: + return python.with_name("powercontext.exe" if os.name == "nt" else "powercontext") + + +def source_database(python: Path, directory: Path, environment: dict[str, str], log: Path) -> dict[str, str | bool]: + port = _available_port() + base_url = f"http://127.0.0.1:{port}" + with log.open("w") as stream: + process = subprocess.Popen( # noqa: S603 -- installed source CLI against a disposable database + [str(console(python)), "server", "run", "--no-env-file", "--host", "127.0.0.1", "--port", str(port)], + cwd=directory, + env=environment, + stdout=stream, + stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0, + start_new_session=os.name != "nt", + text=True, + ) + try: + _wait_until_ready(base_url, process, 90) + with httpx.Client(base_url=base_url, trust_env=False, timeout=30) as client: + response = client.post( + "/v1/scopes", + json={ + "title": "Upgrade fixture", + "summary": "Disposable old-release database for upgrade validation.", + "idempotency_key": "upgrade-fixture", + }, + ) + response.raise_for_status() + scope_id = response.json()["scope_id"] + response = client.post( + "/v1/memory/remember", + json={"scope_id": scope_id, "kind": "fact", "text": "Historical release migration fixture."}, + ) + response.raise_for_status() + require(response.json().get("records"), "Old Server did not persist the fixture") + response = client.post( + "/v1/memory/search", json={"scope_id": scope_id, "query": "historical migration", "mode": "fts"} + ) + response.raise_for_status() + require(response.json().get("hits"), "Old Server cannot retrieve its fixture") + return {"scope_id": scope_id, "memory_readback": True} + finally: + _stop_process(process) + + +def rejected(command: list[str], environment: dict[str, str], directory: Path, log: Path, reason: str) -> None: + with log.open("w", encoding="utf-8") as stream: + process = subprocess.Popen( # noqa: S603 -- installed target CLI, isolated data only + command, + env=environment, + cwd=directory, + stdout=stream, + stderr=subprocess.STDOUT, + text=True, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0, + start_new_session=os.name != "nt", + ) + try: + process.wait(timeout=90) + finally: + _stop_process(process) + output = log.read_text(encoding="utf-8") + require(process.returncode != 0 and reason in output, f"Expected rejection {reason}; inspect {log.name}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wheel", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + wheel = args.wheel.resolve() + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + environment = { + key: value + for key, value in os.environ.items() + if not key.startswith(("POWERCONTEXT_", "PYTEST_")) and key not in {"PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"} + } + environment["NO_PROXY"] = environment["no_proxy"] = "localhost,127.0.0.1" + with tempfile.TemporaryDirectory(prefix="pc-old-release-") as temporary: + directory = Path(temporary) + interpreters = {} + for role, package in {"source": "powercontext[cli,server]==1.3.0", "target": f"{wheel}[cli,server]"}.items(): + venv = directory / role + run( + ["uv", "venv", "--python", "3.12", str(venv)], + environment=environment, + cwd=directory, + log=output / f"{role}-venv.log", + ) + python = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + run( + ["uv", "pip", "install", "--python", str(python), package], + environment=environment, + cwd=directory, + log=output / f"{role}-install.log", + ) + interpreters[role] = python + metadata = {role: installed(python, environment, directory) for role, python in interpreters.items()} + require(metadata["source"]["powercontext"] == "1.3.0", "Wrong source package") + require(metadata["target"]["powercontext"] == wheel_version(wheel), "Wrong target package") + database = directory / "source.db" + environment.update({ + "POWERCONTEXT_HOME": str(directory / "home"), + "POWERCONTEXT_SERVER_DATABASE_URL": f"sqlite+aiosqlite:///{database}", + "POWERCONTEXT_SERVER_ACCESS_MODE": "disabled", + }) + seed = source_database(interpreters["source"], directory, environment, output / "source.log") + with sqlite3.connect(database) as connection: + schema = connection.execute( + "SELECT type, name, tbl_name, sql FROM sqlite_schema ORDER BY type, name" + ).fetchall() + tables = {row[1] for row in schema if row[0] == "table"} + require( + {"pc_artifact_candidate_heads", "pc_artifact_candidate_versions", "pc_scopes", "pc_artifacts"} <= tables, + "Source fixture lacks historical Server objects", + ) + (output / "source-schema.json").write_text(json.dumps(schema, indent=2) + "\n") + env_file = directory / "deployment.env" + env_file.write_text(f"POWERCONTEXT_SERVER_DATABASE_URL=sqlite+aiosqlite:///{database}\n") + target = str(console(interpreters["target"])) + rejected( + [target, "server", "db-migrate", "status", "--env-file", str(env_file)], + environment, + directory, + output / "status.log", + "unknown_baseline", + ) + rejected( + [target, "server", "run", "--no-env-file", "--host", "127.0.0.1", "--port", str(_available_port())], + environment, + directory, + output / "legacy-startup.log", + "migration_required", + ) + # This second negative fixture intentionally adds only the prototype control + # table to a copy. It must never be mistaken for a completed migration. + versioned = directory / "versioned.db" + shutil.copyfile(database, versioned) + with sqlite3.connect(versioned) as connection: + connection.execute("CREATE TABLE pc_schema_revision (version_num VARCHAR(32) PRIMARY KEY)") + environment["POWERCONTEXT_SERVER_DATABASE_URL"] = f"sqlite+aiosqlite:///{versioned}" + rejected( + [target, "server", "run", "--no-env-file", "--host", "127.0.0.1", "--port", str(_available_port())], + environment, + directory, + output / "versioned-startup.log", + "migration_framework_not_ready", + ) + evidence = { + "kind": "unsupported-upgrade-regression", + "backend": "sqlite", + "engine_version": sqlite3.sqlite_version, + "installed": metadata, + "wheel_sha256": sha256(wheel), + "seed": seed, + "table_count": len(tables), + "status": "passed", + } + (output / "result.json").write_text(json.dumps(evidence, indent=2) + "\n") + print("Actual 1.3.0 full database: all three unsupported-upgrade regressions reproduced.") + + +if __name__ == "__main__": + main() diff --git a/scripts/release_version.py b/scripts/release_version.py index 57bf563e31..538f094d95 100644 --- a/scripts/release_version.py +++ b/scripts/release_version.py @@ -33,6 +33,7 @@ # Match current-release contexts only: migration thresholds, release history, # independent plugin versions and third-party dependencies must retain their meaning. REFERENCE_PREFIXES = { + "release/upgrade-support.json": (r'^ "target_version": "',), "openapi/powercontext.yaml": (r"^ version: ",), "README.md": (*INSTALL_REFERENCES, r"PowerContext "), "README_CN.md": (*INSTALL_REFERENCES, r"PowerContext "), @@ -45,13 +46,13 @@ *INSTALL_REFERENCES, r"PowerContext ", r"package `", - r"^To upgrade to ", + r"^To install ", ), "docs/zh/docs/get-started/install-and-run.md": ( *INSTALL_REFERENCES, r"PowerContext ", r"Python 包版本为 `", - r"^升级到 ", + r"^安装 ", ), "integrations/dsh/plugins/powercontext/README.md": INSTALL_REFERENCES, } diff --git a/scripts/run_upgrade_release_gate.py b/scripts/run_upgrade_release_gate.py new file mode 100644 index 0000000000..89ddb8f0d6 --- /dev/null +++ b/scripts/run_upgrade_release_gate.py @@ -0,0 +1,54 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Select the built wheel and run the same upgrade gate locally and in CI.""" + +import argparse +import json +import tempfile +from pathlib import Path + +from upgrade_release_gate import ROOT, GateError, gate, require + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dist", type=Path, default=Path("dist")) + parser.add_argument("--output", type=Path) + parser.add_argument("--release", action="store_true") + notes = parser.add_mutually_exclusive_group() + notes.add_argument("--release-event", type=Path) + notes.add_argument("--release-notes", type=Path) + args = parser.parse_args() + try: + wheels = list(args.dist.glob("*.whl")) + require(len(wheels) == 1, "Build exactly one release wheel before running upgrade acceptance") + release_notes = None + if args.release_event: + release_notes = json.loads(args.release_event.read_text(encoding="utf-8"))["release"]["body"] or "" + elif args.release_notes: + release_notes = args.release_notes.read_text(encoding="utf-8") + output = args.output or Path(tempfile.mkdtemp(prefix="pc-upgrade-evidence-")) / "report" + gate(ROOT, wheels[0].resolve(), output.resolve(), release=args.release, release_notes=release_notes) + result = json.loads((output / "result.json").read_text(encoding="utf-8")) + print( + f"Upgrade gate passed: {result['mode']}; {len(result['cases'])} accepted upgrade combinations. " + f"Evidence: {output / 'result.json'}" + ) + except (GateError, OSError, ValueError) as error: + raise SystemExit(f"Upgrade gate failed: {error}") from error # noqa: TRY003 -- operator diagnostic + + +if __name__ == "__main__": + main() diff --git a/scripts/upgrade_release_gate.py b/scripts/upgrade_release_gate.py new file mode 100644 index 0000000000..62bdbddce9 --- /dev/null +++ b/scripts/upgrade_release_gate.py @@ -0,0 +1,349 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Gate upgrade claims on this build's complete, installed-release acceptance.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import tempfile +import zipfile +from email.parser import Parser +from pathlib import Path +from typing import Any +from xml.etree import ElementTree + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = Path("release/upgrade-support.json") +MANAGED_INPUTS = ( + "src/powercontext/builtin/persistence", + "src/powercontext/builtin/code", + "src/powercontext/server/identity.py", + "src/powercontext/server/authz", + "src/powercontext/limits.py", +) +PHASES = frozenset({ + "test_source_release_database", + "test_public_migration_commands", + "test_target_startup", + "test_read_write_and_retrieval", + "test_authorization", + "test_candidates_and_processing", + "test_preserved_history_and_cursors", + "test_fresh_upgrade_equivalence", +}) +VERSION = r"\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?" +NOTICE = { + "en": "This release supports fresh installations only; upgrades of existing Server databases are not supported.", + "zh": "此版本仅支持新建部署,尚不支持已有 Server 数据库升级。", # noqa: RUF001 -- Chinese prose +} + + +class GateError(ValueError): + """An upgrade claim has no valid declaration or passing acceptance.""" + + +def require(condition: Any, message: str) -> None: + if not condition: + raise GateError(message) + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def managed_digest(root: Path) -> str: + """Bind the review declaration to all persistence inputs, not a new-file count. + + This deliberately over-approximates schema changes. It is a review trigger, + not a claim to prove SQL or data-format compatibility from Python syntax. + """ + inputs = [root / relative for relative in MANAGED_INPUTS] + files = { + path.relative_to(root).as_posix(): sha256(path) + for location in inputs + for path in (location.rglob("*") if location.is_dir() else [location]) + if path.is_file() and path.suffix in {".py", ".sql", ".json"} + } + require(files, "No managed persistence inputs found") + return hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest() + + +def load_policy(root: Path) -> dict[str, Any]: + policy = json.loads((root / MANIFEST).read_text(encoding="utf-8")) + require(set(policy) == {"format", "target_version", "mode", "schema_review", "cases"}, "Invalid policy fields") + require(policy["format"] == 1, "Unsupported upgrade policy format") + contract = (root / "openapi/powercontext.yaml").read_text(encoding="utf-8") + match = re.search(rf"^ version: ({VERSION})$", contract, re.MULTILINE) + require( + match is not None and match[1] == policy["target_version"], "Upgrade policy target differs from API release" + ) + require(policy["mode"] in {"fresh-install-only", "supported-upgrades"}, "Unknown upgrade policy mode") + review = policy["schema_review"] + require(set(review) == {"persistence_sha256", "rationale"}, "Invalid schema review fields") + require( + isinstance(review["rationale"], str) and review["rationale"].strip(), "Schema compatibility rationale required" + ) + require( + review["persistence_sha256"] == managed_digest(root), "Persistence changed: update the compatibility review" + ) + cases = policy["cases"] + require(isinstance(cases, list), "Upgrade cases must be a list") + require(bool(cases) == (policy["mode"] == "supported-upgrades"), "Upgrade mode and support matrix disagree") + identities: set[tuple[str, str, str]] = set() + for case in cases: + require(set(case) == {"source_version", "backend", "configuration", "suite"}, "Invalid upgrade case fields") + require( + isinstance(case["source_version"], str) and re.fullmatch(VERSION, case["source_version"]), + "Pin a source release", + ) + require(case["backend"] in {"sqlite", "seekdb", "oceanbase"}, "Unknown backend") + require(re.fullmatch(r"[a-z][a-z0-9-]*", case["configuration"]), "Configuration needs a stable identifier") + identity = (case["source_version"], case["backend"], case["configuration"]) + require(identity not in identities, "Duplicate upgrade combination") + identities.add(identity) + suite = (root / case["suite"]).resolve() + require( + suite.is_relative_to((root / "tests/release_upgrades").resolve()), + "Suite must be under tests/release_upgrades", + ) + require( + suite.is_file() and suite.name.startswith("test_") and suite.suffix == ".py", "Missing acceptance suite" + ) + return policy + + +def support_text(policy: dict[str, Any], language: str) -> str: + if policy["mode"] == "fresh-install-only": + return NOTICE[language] + title = ( + "Supported database upgrades (other combinations are unsupported):" + if language == "en" + else "支持的数据库升级组合(其他组合不受支持):" # noqa: RUF001 -- Chinese prose + ) + lines = [title, "", "| Source | Target | Backend | Configuration |", "| --- | --- | --- | --- |"] + lines.extend( + f"| {case['source_version']} | {policy['target_version']} | {case['backend']} | {case['configuration']} |" + for case in policy["cases"] + ) + return "\n".join(lines) + + +def check_docs(root: Path, policy: dict[str, Any], *, write: bool = False) -> None: + for language in ("en", "zh"): + path = root / f"docs/{language}/docs/get-started/install-and-run.md" + content = path.read_text(encoding="utf-8") + pattern = r"(?<=\n).*?(?=\n)" + matches = list(re.finditer(pattern, content, re.DOTALL)) + require(len(matches) == 1, f"{path.name}: exactly one upgrade-support region required") + expected = support_text(policy, language) + if write: + match = matches[0] + path.write_text(content[: match.start()] + expected + content[match.end() :], encoding="utf-8") + else: + require(matches[0][0] == expected, f"{language} upgrade support instructions differ from the policy") + + +def validate_junit(report: Path) -> None: + tree = ElementTree.parse(report) # noqa: S314 -- locally produced acceptance report + cases = list(tree.iter("testcase")) + require(cases, "No acceptance tests executed") + require(not any(list(tree.iter(tag)) for tag in ("failure", "error", "skipped")), "Acceptance failed or skipped") + names = {case.attrib.get("name", "").split("[", 1)[0] for case in cases} + require(names >= PHASES, f"Missing full Server acceptance phases: {sorted(PHASES - names)}") + + +def validate_backend_evidence(path: Path, backend: str) -> dict[str, Any]: + evidence = json.loads(path.read_text(encoding="utf-8")) + require(evidence.get("backend") == backend, "Backend evidence does not match the declared case") + for key in ("engine_version", "driver_version", "source_schema", "target_schema", "fresh_schema"): + require(isinstance(evidence.get(key), str) and evidence[key].strip(), f"Missing backend evidence: {key}") + if backend == "oceanbase": + require(evidence.get("tenant_mode") == "mysql", "OceanBase MySQL tenant evidence required") + require( + isinstance(evidence.get("proxy_version"), str) and evidence["proxy_version"], + "Record proxy version or direct", + ) + return evidence + + +def run(command: list[str], *, environment: dict[str, str], cwd: Path, log: Path) -> None: + with log.open("w", encoding="utf-8") as output: + result = subprocess.run( # noqa: S603 -- fixed tools and reviewed repository test paths, no shell + command, env=environment, cwd=cwd, stdout=output, stderr=subprocess.STDOUT, timeout=1800, check=False + ) + require(result.returncode == 0, f"Command failed; inspect {log.name}") + + +def installed(python: Path, environment: dict[str, str], cwd: Path) -> dict[str, str]: + result = subprocess.run( # noqa: S603 -- isolated environment interpreter + [ + str(python), + "-I", + "-c", + "import json,platform; from importlib.metadata import version; " + "print(json.dumps({'powercontext': version('powercontext'), 'python': platform.python_version()}))", + ], + env=environment, + cwd=cwd, + capture_output=True, + text=True, + check=True, + timeout=30, + ) + return json.loads(result.stdout) + + +def wheel_version(wheel: Path) -> str: + with zipfile.ZipFile(wheel) as archive: + names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] + require(len(names) == 1, "Expected exactly one wheel distribution") + metadata = Parser().parsestr(archive.read(names[0]).decode()) + require(metadata["Name"] == "powercontext", "Expected a PowerContext wheel") + return str(metadata["Version"]) + + +def run_case(root: Path, wheel: Path, case: dict[str, Any], output: Path) -> dict[str, Any]: + output.mkdir(parents=True, exist_ok=False) + # Never inherit a developer's live deployment or pytest selection overrides. + environment = { + key: value + for key, value in os.environ.items() + if not key.startswith(("POWERCONTEXT_", "PYTEST_")) and key not in {"PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"} + } + with tempfile.TemporaryDirectory(prefix="pc-upgrade-") as temporary: + directory = Path(temporary) + extras = "cli,server,seekdb" if case["backend"] == "seekdb" else "cli,server" + interpreters = {} + packages = {"source": f"powercontext[{extras}]=={case['source_version']}", "target": f"{wheel}[{extras}]"} + for role, package in packages.items(): + venv = directory / role + run( + ["uv", "venv", "--python", sys.executable, str(venv)], + environment=environment, + cwd=directory, + log=output / f"{role}-venv.log", + ) + python = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + run( + ["uv", "pip", "install", "--python", str(python), package], + environment=environment, + cwd=directory, + log=output / f"{role}-install.log", + ) + interpreters[role] = python + metadata = {role: installed(python, environment, directory) for role, python in interpreters.items()} + require(metadata["source"]["powercontext"] == case["source_version"], "Source release mismatch") + require(metadata["target"]["powercontext"] == wheel_version(wheel), "Target wheel version mismatch") + environment.update({ + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PC_UPGRADE_SOURCE_PYTHON": str(interpreters["source"]), + "PC_UPGRADE_TARGET_PYTHON": str(interpreters["target"]), + "PC_UPGRADE_BACKEND": case["backend"], + "PC_UPGRADE_CONFIGURATION": case["configuration"], + "PC_UPGRADE_WORK": str(directory), + "PC_UPGRADE_EVIDENCE": str(output / "backend.json"), + }) + report = output / "junit.xml" + run( + [ + sys.executable, + "-m", + "pytest", + "-c", + "/dev/null" if os.name != "nt" else "NUL", + "--confcutdir", + str(root / "tests/release_upgrades"), + "--strict-markers", + "-o", + "xfail_strict=true", + "-ra", + f"--junitxml={report}", + str(root / case["suite"]), + ], + environment=environment, + cwd=directory, + log=output / "acceptance.log", + ) + validate_junit(report) + backend = validate_backend_evidence(output / "backend.json", case["backend"]) + return {"case": case, "installed": metadata, "database": backend, "status": "passed"} + + +def gate(root: Path, wheel: Path, output: Path, *, release: bool = False, release_notes: str | None = None) -> None: + policy = load_policy(root) + check_docs(root, policy) + actual_version = wheel_version(wheel) + if release: + require(actual_version == policy["target_version"], "Wheel version differs from upgrade policy target") + require(release_notes is not None, "Published release notes must declare upgrade support") + require( + support_text(policy, "en") in (release_notes or ""), + "GitHub Release notes must include the exact upgrade support declaration", + ) + # A fresh output directory prevents accepting stale reports from another build. + output.mkdir(parents=True, exist_ok=False) + evidence: dict[str, Any] = { + "policy_sha256": sha256(root / MANIFEST), + "target_version": actual_version, + "target_wheel_sha256": sha256(wheel), + "mode": policy["mode"], + "cases": [], + "status": "failed", + } + try: + for index, case in enumerate(policy["cases"]): + evidence["cases"].append(run_case(root, wheel, case, output / f"case-{index}")) + evidence["status"] = "passed" + finally: + (output / "result.json").write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wheel", type=Path) + parser.add_argument("--output", type=Path) + parser.add_argument("--release", action="store_true") + parser.add_argument("--release-notes", type=Path) + parser.add_argument("--write-docs", action="store_true") + args = parser.parse_args() + try: + policy = load_policy(ROOT) + check_docs(ROOT, policy, write=args.write_docs) + if args.wheel: + require(args.output is not None, "--output is required with --wheel") + gate( + ROOT, + args.wheel.resolve(), + args.output.resolve(), + release=args.release, + release_notes=args.release_notes.read_text(encoding="utf-8") if args.release_notes else None, + ) + else: + require(not args.release, "--release requires the actual release wheel") + print(f"Upgrade policy: {policy['mode']}; {len(policy['cases'])} supported combinations") + except (GateError, OSError, ValueError, subprocess.SubprocessError, ElementTree.ParseError) as error: + print(f"Upgrade release gate failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/release_upgrades/README.md b/tests/release_upgrades/README.md new file mode 100644 index 0000000000..65ebec7430 --- /dev/null +++ b/tests/release_upgrades/README.md @@ -0,0 +1,79 @@ +# Installed-release upgrade acceptance + +`release/upgrade-support.json` is the explicit upgrade support policy. Currently it declares +`fresh-install-only`: **no production database upgrade combination is supported**. An empty matrix in this mode +does not prove a migration works. Full migration integration is tracked in #2154. + +To advertise an upgrade, add a reviewed case with `source_version` (an exact published package version), `backend` +(`sqlite`, `seekdb`, or `oceanbase`), a stable `configuration` identifier, and a `suite` path under this directory. +Set `mode` to `supported-upgrades`. Optional features that change persistent objects need explicit cases/configurations. +No ranges, moving refs, silent backend fallback, or implicit combinations are supported. + +The runner creates separate source/target environments, installs the exact published source package and the built +target wheel, verifies their versions, then executes the reviewed suite in an isolated working directory. Repository +pytest configuration and deployment environment variables are excluded. The suite must exercise those installed +processes, not import the current checkout's runtime in place of either version. Use `-I` when invoking their Python. + +The suite receives: + +- `PC_UPGRADE_SOURCE_PYTHON`, `PC_UPGRADE_TARGET_PYTHON`: isolated installed interpreters. +- `PC_UPGRADE_BACKEND`, `PC_UPGRADE_CONFIGURATION`: the declared case. +- `PC_UPGRADE_WORK`: disposable workspace for both databases, controlled inference fixtures, and environment files. +- `PC_UPGRADE_EVIDENCE`: destination for backend evidence JSON. + +Provide these behavioral tests (additional tests are allowed): + +| Test | Required behavior | +| --- | --- | +| `test_source_release_database` | Start the actual source Server to create its complete database. Populate representative Memory history, references, permissions, Candidates, tasks and cursors through that version's public interfaces; verify the data before shutdown. Do not create a minimal schema by hand. | +| `test_public_migration_commands` | Stop writers, run the target artifact's public maintenance commands, and validate full readiness. A limited bundle verification does not pass. | +| `test_target_startup` | Start the target Server against the migrated database and verify readiness. | +| `test_read_write_and_retrieval` | Read the old records, write new ones, and retrieve both through public interfaces. | +| `test_authorization` | Verify permitted and denied access and preservation of relevant grants. | +| `test_candidates_and_processing` | Verify retained Candidates and task consumption, including idempotency. | +| `test_preserved_history_and_cursors` | Verify exact history/references and unchanged or declared-transformed cursor and receipt semantics. | +| `test_fresh_upgrade_equivalence` | Initialize a fresh target database; compare schema/backend objects and behavior with the upgraded database, explicitly allowing only registered retained/archive objects. | + +Use a session fixture for the shared lifecycle; do not depend on pytest's test execution order. All collected tests +must execute and pass. Empty collection, missing phases, skips (including missing backends), xfail, failure, command +failure, and missing/malformed reports fail the gate. A real OceanBase suite must provision an isolated database and +fail if its required environment is unavailable; it cannot replace execution with mocks. Controlled model responses +may test domain behavior, but do not establish model quality. + +Write backend evidence containing `backend`, nonempty `engine_version`, `driver_version`, and recorded schema +inventories or their digests in `source_schema`, `target_schema`, and `fresh_schema`. OceanBase additionally requires +`tenant_mode: "mysql"` and `proxy_version` (or `direct`). Do not record credentials or business payloads. The controller +records installed Python/package versions, target wheel SHA-256, policy SHA-256, and the individual case result. + +Evidence is created during the gate run in a new directory. There is no option to import old successful reports. +Test names and metadata do not themselves prove correctness: reviewers must inspect the assertions and confirm +they follow the public operations and compare actual persisted data. This contract is intended to host the real +upgrade acceptance added with #2154, without claiming that it already exists. + +For example, after implementing and reviewing an actual suite, a case would have this shape: + +```json +{ + "source_version": "1.3.0", + "backend": "sqlite", + "configuration": "full-server-no-vector", + "suite": "tests/release_upgrades/test_sqlite_130.py" +} +``` + +This example is not an existing supported case. Regenerate installation support regions with +`uv run python scripts/upgrade_release_gate.py --write-docs` and run `make upgrade-policy-check`. When persistence +inputs change, calculate `managed_digest(ROOT)` from `upgrade_release_gate`, review the compatibility impact, and +update both the digest and rationale in `schema_review`. The inputs include persistence, optional code storage, +Server identity and authorization, and shared limits used in table definitions. Add new schema owners to +`MANAGED_INPUTS` when introducing them. The digest intentionally includes non-schema code: it forces a review of +changes, but does not infer semantic compatibility or replace real upgrade tests. + +Build exactly one wheel into `dist/`, then run `make upgrade-release-test`. Before release, additionally run +`uv run python scripts/run_upgrade_release_gate.py --release --release-notes /path/to/notes.md`. The notes must contain +the exact English support declaration generated in the installation guide. The release workflow performs this check +against the GitHub event's notes and gates both PyPI publication and release assets on the same tested wheel. + +Repository administrators must require **Upgrade release gate** in branch protection. Workflow code cannot configure +that setting. Because the release workflow starts on `release.published`, it can block package/asset publication, +not the earlier manual creation of a GitHub Release; run the prepublication command before publishing that page. diff --git a/tests/test_release_version.py b/tests/test_release_version.py index 9629fd7c07..74a5bca627 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -49,6 +49,7 @@ def release_repo(tmp_path: Path) -> Path: source = Path(__file__).parents[1] for relative in ( "scripts/release_version.py", + "release/upgrade-support.json", "openapi/powercontext.yaml", *INSTALL_PAGES, *HISTORICAL_AND_INDEPENDENT_FILES, @@ -98,13 +99,11 @@ def test_release_updates_installation_references_and_preserves_history(release_r english = (release_repo / "docs/en/docs/get-started/install-and-run.md").read_text(encoding="utf-8") chinese = (release_repo / "docs/zh/docs/get-started/install-and-run.md").read_text(encoding="utf-8") assert f"package `{version}`" in english - assert f"To upgrade to {version}:" in english + assert f"To install {version} in a separate environment" in english assert f"Python 包版本为 `{version}`" in chinese - assert f"升级到 {version}" in chinese - assert "Version 1.1.0 upgrades legacy" in english - assert "1.0.0 also need" in english - assert "1.1.0 会在 Server 启动时升级旧标签表约束" in chinese - assert "对于 1.0.0 之前的数据库" in chinese + assert f"安装 {version} 到独立环境" in chinese + assert "upgrades of existing Server databases are not supported" in english + assert "尚不支持已有 Server 数据库升级" in chinese dsh = (release_repo / "integrations/dsh/plugins/powercontext/README.md").read_text(encoding="utf-8") assert "Release 1.1.0 includes direct-operation Scope failure handling" in dsh for relative, original in preserved.items(): diff --git a/tests/test_upgrade_release_gate.py b/tests/test_upgrade_release_gate.py new file mode 100644 index 0000000000..2909bdbcba --- /dev/null +++ b/tests/test_upgrade_release_gate.py @@ -0,0 +1,210 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Release-blocking regressions, not evidence that any database upgrade works.""" + +import json +import zipfile +from pathlib import Path +from xml.etree import ElementTree + +import pytest +import upgrade_release_gate as gate +import yaml + + +@pytest.fixture +def release(tmp_path): + persistence = tmp_path / "src/powercontext/builtin/persistence/tables.py" + persistence.parent.mkdir(parents=True) + persistence.write_text("# disposable persistence input\n") + contract = tmp_path / "openapi/powercontext.yaml" + contract.parent.mkdir() + contract.write_text("info:\n version: 2.0.0\n") + (tmp_path / "release").mkdir() + policy = { + "format": 1, + "target_version": "2.0.0", + "mode": "fresh-install-only", + "schema_review": {"persistence_sha256": gate.managed_digest(tmp_path), "rationale": "No upgrades supported"}, + "cases": [], + } + (tmp_path / gate.MANIFEST).write_text(json.dumps(policy)) + for language in ("en", "zh"): + page = tmp_path / f"docs/{language}/docs/get-started/install-and-run.md" + page.parent.mkdir(parents=True) + page.write_text("\nplaceholder\n\n") + gate.check_docs(tmp_path, policy, write=True) + wheel = tmp_path / "powercontext-2.0.0-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("powercontext-2.0.0.dist-info/METADATA", "Name: powercontext\nVersion: 2.0.0\n") + return tmp_path, wheel, policy + + +def test_fresh_install_policy_does_not_claim_upgrade_acceptance(release): + root, wheel, policy = release + gate.gate(root, wheel, root / "evidence", release=True, release_notes=gate.support_text(policy, "en")) + result = json.loads((root / "evidence/result.json").read_text()) + assert result["mode"] == "fresh-install-only" and result["cases"] == [] + assert result["target_wheel_sha256"] == gate.sha256(wheel) + + +def test_release_notes_cannot_omit_the_support_limit(release): + root, wheel, _ = release + with pytest.raises(gate.GateError, match="notes"): + gate.gate(root, wheel, root / "evidence", release=True, release_notes="Upgrade your deployment now.") + + +def test_release_rejects_wrong_artifact_version(release): + root, wheel, _ = release + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("powercontext-9.0.0.dist-info/METADATA", "Name: powercontext\nVersion: 9.0.0\n") + with pytest.raises(gate.GateError, match="Wheel version"): + gate.gate(root, wheel, root / "evidence", release=True) + + +def test_schema_change_needs_a_new_compatibility_review(release): + root, _, _ = release + (root / "src/powercontext/builtin/persistence/tables.py").write_text("# different schema\n") + with pytest.raises(gate.GateError, match="Persistence changed"): + gate.load_policy(root) + + +def test_supported_mode_cannot_succeed_with_an_empty_matrix(release): + root, _, policy = release + policy["mode"] = "supported-upgrades" + (root / gate.MANIFEST).write_text(json.dumps(policy)) + with pytest.raises(gate.GateError, match="matrix disagree"): + gate.load_policy(root) + + +@pytest.mark.parametrize( + "relative", + [ + "src/powercontext/server/identity.py", + "src/powercontext/server/authz/repository.py", + "src/powercontext/builtin/code/seekdb.py", + "src/powercontext/limits.py", + ], +) +def test_schema_owners_outside_persistence_need_compatibility_review(release, relative): + root, _, _ = release + owner = root / relative + owner.parent.mkdir(parents=True, exist_ok=True) + owner.write_text("# changed managed schema input\n") + with pytest.raises(gate.GateError, match="Persistence changed"): + gate.load_policy(root) + + +def supported_case(root, policy): + suite = root / "tests/release_upgrades/test_example.py" + suite.parent.mkdir(parents=True) + suite.write_text("# This fixture is only used to exercise gate failures.\n") + policy["mode"] = "supported-upgrades" + policy["cases"] = [ + { + "source_version": "1.3.0", + "backend": "sqlite", + "configuration": "full-server", + "suite": suite.relative_to(root).as_posix(), + } + ] + (root / gate.MANIFEST).write_text(json.dumps(policy)) + gate.check_docs(root, policy, write=True) + + +@pytest.mark.parametrize( + "blocker", ["migration_required", "unmanaged full Server layout", "migration_framework_not_ready"] +) +def test_any_upgrade_stage_failure_blocks_release_and_records_failure(release, monkeypatch, blocker): + root, wheel, policy = release + supported_case(root, policy) + + def fail(*_args): + raise gate.GateError(blocker) + + monkeypatch.setattr(gate, "run_case", fail) + with pytest.raises(gate.GateError, match=blocker): + gate.gate(root, wheel, root / "evidence") + assert json.loads((root / "evidence/result.json").read_text())["status"] == "failed" + + +def test_missing_suite_cannot_be_advertised(release): + root, _, policy = release + supported_case(root, policy) + (root / policy["cases"][0]["suite"]).unlink() + with pytest.raises(gate.GateError, match="Missing acceptance suite"): + gate.load_policy(root) + + +def test_installation_guidance_cannot_drift_from_matrix(release): + root, _, policy = release + page = root / "docs/en/docs/get-started/install-and-run.md" + page.write_text(page.read_text().replace(gate.NOTICE["en"], "All upgrades are supported.")) + with pytest.raises(gate.GateError, match="instructions differ"): + gate.check_docs(root, policy) + + +@pytest.mark.parametrize("invalid", ["empty", "missing-startup", "skipped", "failure", "error", "truncated"]) +def test_partial_or_skipped_acceptance_is_not_passing_evidence(tmp_path, invalid): + report = tmp_path / "junit.xml" + suite = ElementTree.Element("testsuite") + for phase in sorted(gate.PHASES): + if invalid == "empty" or (invalid == "missing-startup" and phase == "test_target_startup"): + continue + case = ElementTree.SubElement(suite, "testcase", name=phase) + if phase == "test_target_startup" and invalid in {"skipped", "failure", "error"}: + ElementTree.SubElement(case, invalid) + ElementTree.ElementTree(suite).write(report) + if invalid == "truncated": + report.write_text("") + with pytest.raises((gate.GateError, ElementTree.ParseError)): + gate.validate_junit(report) + + +def test_complete_passing_report_is_accepted(tmp_path): + report = tmp_path / "junit.xml" + suite = ElementTree.Element("testsuite") + for phase in gate.PHASES: + ElementTree.SubElement(suite, "testcase", name=phase) + ElementTree.ElementTree(suite).write(report) + gate.validate_junit(report) + + +def test_missing_backend_evidence_fails_even_with_a_passing_report(tmp_path): + report = tmp_path / "backend.json" + report.write_text(json.dumps({"backend": "oceanbase", "engine_version": "4.6.3"})) + with pytest.raises(gate.GateError, match="driver_version"): + gate.validate_backend_evidence(report, "oceanbase") + + +def test_old_evidence_directory_cannot_be_reused(release): + root, wheel, _ = release + gate.gate(root, wheel, root / "evidence") + with pytest.raises(FileExistsError): + gate.gate(root, wheel, root / "evidence") + + +def test_publishing_jobs_depend_on_nonoptional_preupload_upgrade_gate(): + root = Path(__file__).parents[1] + workflow = yaml.safe_load((root / ".github/workflows/release.yml").read_text()) + build = workflow["jobs"]["release-build"] + steps = build["steps"] + index = next(i for i, step in enumerate(steps) if "run_upgrade_release_gate.py" in step.get("run", "")) + assert "if" not in steps[index] and not steps[index].get("continue-on-error") + assert "--release --release-event" in steps[index]["run"] + upload = next(i for i, step in enumerate(steps) if step.get("with", {}).get("name") == "release-dists") + assert index < upload + for name in ("pypi-publish", "release-assets"): + assert workflow["jobs"][name]["needs"] == "release-build" From eff8bacc92be8a4ba7f307434b7db812cebff465 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Thu, 22 Oct 2026 10:42:51 +0800 Subject: [PATCH 02/11] ci(release): discover upgrade sources and exercise embedded seekdb --- .github/workflows/master.yml | 9 +- docs/en/development/releasing.md | 15 ++- docs/zh/development/releasing.md | 12 +- release/upgrade-support.json | 20 +++- scripts/ci_legacy_upgrade_rejection.py | 113 ++++++++++++------ scripts/run_upgrade_release_gate.py | 4 +- scripts/upgrade_database_probe.py | 85 +++++++++++++ scripts/upgrade_release_gate.py | 49 ++++++-- scripts/upgrade_release_matrix.py | 105 ++++++++++++++++ tests/release_upgrades/README.md | 70 +++++++++-- tests/test_upgrade_release_gate.py | 158 ++++++++++++++++++++++++- 11 files changed, 570 insertions(+), 70 deletions(-) create mode 100644 scripts/upgrade_database_probe.py create mode 100644 scripts/upgrade_release_matrix.py diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index d9c4d2ad9c..c6489517a7 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -41,11 +41,14 @@ jobs: run: >- uv run python scripts/run_upgrade_release_gate.py --output "$RUNNER_TEMP/upgrade-release-evidence" - - name: Reproduce unsupported full 1.3.0 database upgrade + - name: Reproduce unsupported SQLite and embedded seekDB upgrades run: | wheel=$(find dist -name '*.whl' -print -quit) - uv run python scripts/ci_legacy_upgrade_rejection.py \ - --wheel "$wheel" --output "$RUNNER_TEMP/upgrade-release-evidence/legacy-rejection" + for backend in sqlite seekdb; do + uv run python scripts/ci_legacy_upgrade_rejection.py \ + --backend "$backend" --respect-policy --wheel "$wheel" \ + --output "$RUNNER_TEMP/upgrade-release-evidence/legacy-$backend" + done - name: Preserve upgrade acceptance evidence if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 diff --git a/docs/en/development/releasing.md b/docs/en/development/releasing.md index e95ae9c0b6..878945cf10 100644 --- a/docs/en/development/releasing.md +++ b/docs/en/development/releasing.md @@ -129,8 +129,12 @@ installation pins are synchronized. ### Database upgrade support -`release/upgrade-support.json` declares the target version and every supported source version, backend, and -configuration. The current policy is `fresh-install-only`; existing Server database upgrades are unsupported. +`release/upgrade-support.json` declares the target version, source-version selection policy, backends, and +configurations. `automatic_sources` selects every stable PyPI release from `minimum_version` up to, but excluding, +the actual target wheel. Newly published stable versions become source cases on subsequent runs without adding +per-version tests. The runner expands every source across all backend profiles and records source wheel SHA-256s. +The current policy is `fresh-install-only`; existing Server database upgrades are unsupported. Its null suite paths +record candidates only, not accepted upgrades. Supported mode requires implemented suites for every backend profile. Do not advertise a supported upgrade until its installed-release acceptance suite is implemented and passes. The suite contract and evidence requirements are in `tests/release_upgrades/README.md` in the repository. @@ -152,9 +156,10 @@ in separate environments. Missing tests, skipped backends, failures, and missing `fresh-install-only` policy check does not establish an upgrade path. Ordinary PR builds may have a development version; the `--release` check must run against the final tagged wheel without overriding its version. -The **Upgrade release gate** CI job also reproduces the known unsupported upgrade rejections using a real 1.3.0 -SQLite Server database. Replace that negative fixture with supported-path acceptance when implementing the full -migration chain. Repository administrators must make this job required in branch protection. +The **Upgrade release gate** CI job also reproduces the known unsupported upgrade rejections using real 1.3.0 +SQLite and embedded seekDB Server databases. It installs and starts seekDB inside the runner with a disposable +directory; no external service is needed. Once supported mode is activated, these negative assertions retire and +the full discovered upgrade matrix must pass. Repository administrators must make this job required in branch protection. The release workflow gates distribution upload, PyPI publication, and release assets on the acceptance result and archives its evidence. It runs after `release.published`, so it cannot prevent the earlier manual publication of the GitHub Release page: run the command above before that action. `make publish` requires diff --git a/docs/zh/development/releasing.md b/docs/zh/development/releasing.md index bfc18fc73c..760ea31da7 100644 --- a/docs/zh/development/releasing.md +++ b/docs/zh/development/releasing.md @@ -122,8 +122,11 @@ DSH README 中说明某项能力已包含在 `1.1.0` 的文字同样属于历史 ### 数据库升级支持范围 -`release/upgrade-support.json` 声明目标版本,以及每个受支持的来源版本、后端和配置组合。当前策略为 -`fresh-install-only`,不支持已有 Server 数据库升级。只有实现并通过已安装发行包的完整升级验收,才能声明支持 +`release/upgrade-support.json` 声明目标版本、来源版本选择策略、后端和配置。`automatic_sources` 从 PyPI 自动选择 +大于等于 `minimum_version`、小于实际目标 wheel 的全部稳定版本,再与每个后端配置展开组合。新稳定版发布后, +后续运行会自动将其加入来源,不必逐版本新增测试。证据会记录具体来源 wheel 的 SHA-256。 +当前策略为 `fresh-install-only`,不支持已有 Server 数据库升级;配置中的空套件路径只生成候选组合,不算验收通过。 +启用支持升级模式时,每个后端配置都必须有真实套件。只有实现并通过已安装发行包的完整升级验收,才能声明支持 对应组合。验收契约和证据要求见仓库中的 `tests/release_upgrades/README.md`。 `make check` 和 `make release-check` 会核对策略及双语安装说明。受管持久化文件变化时,必须审查兼容性影响, @@ -141,8 +144,9 @@ uv run python scripts/run_upgrade_release_gate.py --release --release-notes /pat 旧版发行包和目标 wheel。测试缺失、后端跳过、执行失败或证据缺失都会阻断检查。`fresh-install-only` 策略检查 通过不表示存在升级路径。普通 PR 构建允许使用开发版本;`--release` 必须验证最终 tag 构建的 wheel,不能覆盖版本号。 -CI 中的 **Upgrade release gate** 还会使用真实 1.3.0 SQLite Server 数据库复现已知的不支持升级拒绝行为。 -完整迁移链实现后,应将这一负向场景替换为支持路径的验收。仓库管理员需要在分支保护中将该检查设为必需。 +CI 中的 **Upgrade release gate** 还会使用真实 1.3.0 SQLite 和嵌入式 seekDB Server 数据库复现已知的升级拒绝行为。 +seekDB 直接在 runner 的临时目录中启动,不需要外部服务。启用支持升级模式后,固定的拒绝断言退出,自动展开的 +全部成功升级组合必须通过验收。仓库管理员需要在分支保护中将该检查设为必需。 发布工作流只有验收通过才会上传发行包、发布 PyPI 和上传 Release 附件,并归档验收证据。工作流在 `release.published` 后运行,无法阻止之前手动发布 GitHub Release 页面,因此必须先运行上述命令再发布页面。 `make publish` 要求提供 `UPGRADE_RELEASE_NOTES=/path/to/release-notes.md`,并在上传软件包之前执行相同门禁。 diff --git a/release/upgrade-support.json b/release/upgrade-support.json index a1a0dff078..4e61ff6624 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -1,10 +1,26 @@ { - "format": 1, + "format": 2, "target_version": "1.4.0", "mode": "fresh-install-only", "schema_review": { "persistence_sha256": "ca3b475f2d0c883e19291a8d8b1dadbe50f93ebd93aad550a9112a3497737927", "rationale": "The full Server upgrade framework is incomplete (#2154). Existing databases are unsupported; no production upgrade combination is advertised until installed-release acceptance passes." }, - "cases": [] + "cases": [], + "automatic_sources": { + "strategy": "all-stable-since", + "minimum_version": "1.3.0", + "profiles": [ + { + "backend": "sqlite", + "configuration": "full-server-no-vector", + "suite": null + }, + { + "backend": "seekdb", + "configuration": "full-server-no-vector", + "suite": null + } + ] + } } diff --git a/scripts/ci_legacy_upgrade_rejection.py b/scripts/ci_legacy_upgrade_rejection.py index cefdf44def..7bedf3a5f2 100644 --- a/scripts/ci_legacy_upgrade_rejection.py +++ b/scripts/ci_legacy_upgrade_rejection.py @@ -12,10 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Reproduce unsupported 1.3.0 upgrade rejection on an actual old SQLite Server. +"""Reproduce unsupported 1.3.0 upgrades on an actual SQLite or embedded seekDB Server. -This is a negative regression, never positive upgrade acceptance. Once the path -is supported, replace this fixture with the full matrix suite before advertising it. +This is a negative regression, never positive upgrade acceptance. CI retires +these assertions once the supported-path matrix becomes mandatory. """ from __future__ import annotations @@ -23,15 +23,13 @@ import argparse import json import os -import shutil -import sqlite3 import subprocess import tempfile from pathlib import Path import httpx from ci_release_smoke import _available_port, _stop_process, _wait_until_ready -from upgrade_release_gate import installed, require, run, sha256, wheel_version +from upgrade_release_gate import ROOT, installed, load_policy, require, run, sha256, wheel_version def console(python: Path) -> Path: @@ -53,7 +51,7 @@ def source_database(python: Path, directory: Path, environment: dict[str, str], text=True, ) try: - _wait_until_ready(base_url, process, 90) + _wait_until_ready(base_url, process, 180) with httpx.Client(base_url=base_url, trust_env=False, timeout=30) as client: response = client.post( "/v1/scopes", @@ -81,7 +79,9 @@ def source_database(python: Path, directory: Path, environment: dict[str, str], _stop_process(process) -def rejected(command: list[str], environment: dict[str, str], directory: Path, log: Path, reason: str) -> None: +def rejected( + command: list[str], environment: dict[str, str], directory: Path, log: Path, reasons: tuple[str, ...] +) -> None: with log.open("w", encoding="utf-8") as stream: process = subprocess.Popen( # noqa: S603 -- installed target CLI, isolated data only command, @@ -94,31 +94,57 @@ def rejected(command: list[str], environment: dict[str, str], directory: Path, l start_new_session=os.name != "nt", ) try: - process.wait(timeout=90) + process.wait(timeout=180) finally: _stop_process(process) output = log.read_text(encoding="utf-8") - require(process.returncode != 0 and reason in output, f"Expected rejection {reason}; inspect {log.name}") + require( + process.returncode != 0 and any(reason in output for reason in reasons), + f"Expected rejection {reasons}; inspect {log.name}", + ) def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--wheel", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--backend", choices=("sqlite", "seekdb"), default="sqlite") + parser.add_argument( + "--respect-policy", + action="store_true", + help="Retire rejection assertions once supported-path acceptance is required", + ) args = parser.parse_args() wheel = args.wheel.resolve() output = args.output.resolve() output.mkdir(parents=True, exist_ok=False) + if args.respect_policy and load_policy(ROOT)["mode"] == "supported-upgrades": + (output / "result.json").write_text( + json.dumps( + { + "kind": "unsupported-upgrade-regression", + "backend": args.backend, + "status": "not-applicable", + "reason": "Supported upgrades are evaluated by the required acceptance gate.", + }, + indent=2, + ) + + "\n" + ) + print("Historical rejection assertions retired; the supported upgrade matrix must pass instead.") + return environment = { key: value for key, value in os.environ.items() if not key.startswith(("POWERCONTEXT_", "PYTEST_")) and key not in {"PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"} } environment["NO_PROXY"] = environment["no_proxy"] = "localhost,127.0.0.1" - with tempfile.TemporaryDirectory(prefix="pc-old-release-") as temporary: + # A short pathname avoids the embedded engine's Unix socket length limit. + with tempfile.TemporaryDirectory(prefix="pc-up-", dir="/tmp" if os.name != "nt" else None) as temporary: # noqa: S108 -- mkdtemp creates a private random directory directory = Path(temporary) interpreters = {} - for role, package in {"source": "powercontext[cli,server]==1.3.0", "target": f"{wheel}[cli,server]"}.items(): + extras = "cli,server,seekdb" if args.backend == "seekdb" else "cli,server" + for role, package in {"source": f"powercontext[{extras}]==1.3.0", "target": f"{wheel}[{extras}]"}.items(): venv = directory / role run( ["uv", "venv", "--python", "3.12", str(venv)], @@ -137,58 +163,77 @@ def main() -> None: metadata = {role: installed(python, environment, directory) for role, python in interpreters.items()} require(metadata["source"]["powercontext"] == "1.3.0", "Wrong source package") require(metadata["target"]["powercontext"] == wheel_version(wheel), "Wrong target package") - database = directory / "source.db" + database = directory / "database" + database_environment = ( + {"POWERCONTEXT_SERVER_DATABASE_KIND": "seekdb", "POWERCONTEXT_SERVER_DATABASE_PATH": str(database)} + if args.backend == "seekdb" + else {"POWERCONTEXT_SERVER_DATABASE_URL": f"sqlite+aiosqlite:///{database}"} + ) environment.update({ "POWERCONTEXT_HOME": str(directory / "home"), - "POWERCONTEXT_SERVER_DATABASE_URL": f"sqlite+aiosqlite:///{database}", + **database_environment, "POWERCONTEXT_SERVER_ACCESS_MODE": "disabled", }) seed = source_database(interpreters["source"], directory, environment, output / "source.log") - with sqlite3.connect(database) as connection: - schema = connection.execute( - "SELECT type, name, tbl_name, sql FROM sqlite_schema ORDER BY type, name" - ).fetchall() - tables = {row[1] for row in schema if row[0] == "table"} + probe = [ + "-I", + str(ROOT / "scripts/upgrade_database_probe.py"), + "--backend", + args.backend, + "--database", + str(database), + ] + for role, python in interpreters.items(): + run( + [str(python), *probe, "--output", str(output / f"{role}-schema.json")], + environment=environment, + cwd=directory, + log=output / f"{role}-probe.log", + ) + schema = json.loads((output / "source-schema.json").read_text()) + target_schema = json.loads((output / "target-schema.json").read_text()) + tables = set(schema["tables"]) require( {"pc_artifact_candidate_heads", "pc_artifact_candidate_versions", "pc_scopes", "pc_artifacts"} <= tables, "Source fixture lacks historical Server objects", ) - (output / "source-schema.json").write_text(json.dumps(schema, indent=2) + "\n") env_file = directory / "deployment.env" - env_file.write_text(f"POWERCONTEXT_SERVER_DATABASE_URL=sqlite+aiosqlite:///{database}\n") + env_file.write_text("".join(f"{key}={value}\n" for key, value in database_environment.items())) target = str(console(interpreters["target"])) rejected( [target, "server", "db-migrate", "status", "--env-file", str(env_file)], environment, directory, output / "status.log", - "unknown_baseline", + ("unknown_baseline", "incompatible_schema") if args.backend == "seekdb" else ("unknown_baseline",), ) rejected( [target, "server", "run", "--no-env-file", "--host", "127.0.0.1", "--port", str(_available_port())], environment, directory, output / "legacy-startup.log", - "migration_required", + ("migration_required",), + ) + # Only after both historical checks, mutate this disposable fixture into + # a deliberately incomplete versioned state. This is not a migration. + run( + [str(interpreters["target"]), *probe, "--add-revision", "--output", str(output / "versioned-schema.json")], + environment=environment, + cwd=directory, + log=output / "versioned-probe.log", ) - # This second negative fixture intentionally adds only the prototype control - # table to a copy. It must never be mistaken for a completed migration. - versioned = directory / "versioned.db" - shutil.copyfile(database, versioned) - with sqlite3.connect(versioned) as connection: - connection.execute("CREATE TABLE pc_schema_revision (version_num VARCHAR(32) PRIMARY KEY)") - environment["POWERCONTEXT_SERVER_DATABASE_URL"] = f"sqlite+aiosqlite:///{versioned}" rejected( [target, "server", "run", "--no-env-file", "--host", "127.0.0.1", "--port", str(_available_port())], environment, directory, output / "versioned-startup.log", - "migration_framework_not_ready", + ("migration_framework_not_ready",), ) evidence = { "kind": "unsupported-upgrade-regression", - "backend": "sqlite", - "engine_version": sqlite3.sqlite_version, + "backend": args.backend, + "source_database": {key: value for key, value in schema.items() if key != "tables"}, + "target_database": {key: value for key, value in target_schema.items() if key != "tables"}, "installed": metadata, "wheel_sha256": sha256(wheel), "seed": seed, @@ -196,7 +241,7 @@ def main() -> None: "status": "passed", } (output / "result.json").write_text(json.dumps(evidence, indent=2) + "\n") - print("Actual 1.3.0 full database: all three unsupported-upgrade regressions reproduced.") + print(f"Actual 1.3.0 {args.backend} database: all three unsupported-upgrade regressions reproduced.") if __name__ == "__main__": diff --git a/scripts/run_upgrade_release_gate.py b/scripts/run_upgrade_release_gate.py index 89ddb8f0d6..b585530f15 100644 --- a/scripts/run_upgrade_release_gate.py +++ b/scripts/run_upgrade_release_gate.py @@ -43,7 +43,9 @@ def main() -> None: gate(ROOT, wheels[0].resolve(), output.resolve(), release=args.release, release_notes=release_notes) result = json.loads((output / "result.json").read_text(encoding="utf-8")) print( - f"Upgrade gate passed: {result['mode']}; {len(result['cases'])} accepted upgrade combinations. " + f"Upgrade gate passed: {result['mode']}; " + f"{len(result['resolved_matrix']['candidate_cases'])} discovered candidates; " + f"{len(result['cases'])} accepted upgrade combinations. " f"Evidence: {output / 'result.json'}" ) except (GateError, OSError, ValueError) as error: diff --git a/scripts/upgrade_database_probe.py b/scripts/upgrade_database_probe.py new file mode 100644 index 0000000000..8b8822979d --- /dev/null +++ b/scripts/upgrade_database_probe.py @@ -0,0 +1,85 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Inspect a disposable database using the installed release's own driver. + +Run only after the Server has exited. --add-revision creates a deliberately +incomplete control table for a negative fixture; it never represents a migration. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sqlite3 +from importlib import import_module +from importlib.metadata import version +from pathlib import Path +from typing import Any + +REVISION_DDL = "CREATE TABLE pc_schema_revision (version_num VARCHAR(32) PRIMARY KEY)" + + +async def inspect(backend: str, database: Path, *, add_revision: bool) -> dict[str, Any]: + if backend == "sqlite": + with sqlite3.connect(database) as connection: + if add_revision: + connection.execute(REVISION_DDL) + rows = connection.execute("SELECT name, sql FROM sqlite_schema WHERE type='table' ORDER BY name").fetchall() + return {"engine_version": sqlite3.sqlite_version, "driver_version": version("aiosqlite"), "tables": dict(rows)} + + # Use the driver's public API, not a current-checkout Profile that could run + # startup guards or mutate an old schema before the target CLI is tested. + binding = import_module("pylibseekdb") + pymysql = import_module("pymysql") + instance = await binding.aopen(str(database)) + try: + with ( + pymysql.connect(**instance.connection_options(), database="test", autocommit=True) as connection, + connection.cursor() as cursor, + ): + if add_revision: + cursor.execute(REVISION_DDL) + cursor.execute("SELECT VERSION()") + engine = str(cursor.fetchone()[0]) + cursor.execute("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'") + names = sorted(row[0] for row in cursor.fetchall()) + tables = {} + for name in names: + cursor.execute("SHOW CREATE TABLE `" + name.replace("`", "``") + "`") + tables[name] = cursor.fetchone()[1] + return { + "engine_version": engine, + "driver_version": version("pymysql"), + "embedded_binding_version": version("pylibseekdb"), + "tables": tables, + } + finally: + instance.close() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--backend", choices=("sqlite", "seekdb"), required=True) + parser.add_argument("--database", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--add-revision", action="store_true") + args = parser.parse_args() + result = asyncio.run(inspect(args.backend, args.database, add_revision=args.add_revision)) + args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/scripts/upgrade_release_gate.py b/scripts/upgrade_release_gate.py index 62bdbddce9..ba0178a88d 100644 --- a/scripts/upgrade_release_gate.py +++ b/scripts/upgrade_release_gate.py @@ -30,6 +30,8 @@ from typing import Any from xml.etree import ElementTree +from upgrade_release_matrix import STABLE, resolve_matrix + ROOT = Path(__file__).resolve().parents[1] MANIFEST = Path("release/upgrade-support.json") MANAGED_INPUTS = ( @@ -88,8 +90,11 @@ def managed_digest(root: Path) -> str: def load_policy(root: Path) -> dict[str, Any]: policy = json.loads((root / MANIFEST).read_text(encoding="utf-8")) - require(set(policy) == {"format", "target_version", "mode", "schema_review", "cases"}, "Invalid policy fields") - require(policy["format"] == 1, "Unsupported upgrade policy format") + require( + set(policy) == {"format", "target_version", "mode", "schema_review", "cases", "automatic_sources"}, + "Invalid policy fields", + ) + require(policy["format"] == 2, "Unsupported upgrade policy format") contract = (root / "openapi/powercontext.yaml").read_text(encoding="utf-8") match = re.search(rf"^ version: ({VERSION})$", contract, re.MULTILINE) require( @@ -106,7 +111,21 @@ def load_policy(root: Path) -> dict[str, Any]: ) cases = policy["cases"] require(isinstance(cases, list), "Upgrade cases must be a list") - require(bool(cases) == (policy["mode"] == "supported-upgrades"), "Upgrade mode and support matrix disagree") + automatic = policy["automatic_sources"] + profiles = [] + if automatic is not None: + require(set(automatic) == {"strategy", "minimum_version", "profiles"}, "Invalid automatic source policy") + require(automatic["strategy"] == "all-stable-since", "Unknown source selection strategy") + require( + isinstance(automatic["minimum_version"], str) and re.fullmatch(STABLE, automatic["minimum_version"]), + "Minimum source must be a stable version", + ) + profiles = automatic["profiles"] + require(isinstance(profiles, list) and profiles, "Automatic sources require backend profiles") + for profile in profiles: + require(set(profile) == {"backend", "configuration", "suite"}, "Invalid automatic backend profile") + supported = policy["mode"] == "supported-upgrades" + require(bool(cases or profiles) if supported else not cases, "Upgrade mode and support matrix disagree") identities: set[tuple[str, str, str]] = set() for case in cases: require(set(case) == {"source_version", "backend", "configuration", "suite"}, "Invalid upgrade case fields") @@ -114,11 +133,15 @@ def load_policy(root: Path) -> dict[str, Any]: isinstance(case["source_version"], str) and re.fullmatch(VERSION, case["source_version"]), "Pin a source release", ) + for case in [*cases, *profiles]: require(case["backend"] in {"sqlite", "seekdb", "oceanbase"}, "Unknown backend") require(re.fullmatch(r"[a-z][a-z0-9-]*", case["configuration"]), "Configuration needs a stable identifier") - identity = (case["source_version"], case["backend"], case["configuration"]) + identity = (case.get("source_version", "automatic"), case["backend"], case["configuration"]) require(identity not in identities, "Duplicate upgrade combination") identities.add(identity) + if case["suite"] is None: + require(not supported, "Supported upgrades require a real acceptance suite for every backend profile") + continue suite = (root / case["suite"]).resolve() require( suite.is_relative_to((root / "tests/release_upgrades").resolve()), @@ -143,6 +166,11 @@ def support_text(policy: dict[str, Any], language: str) -> str: f"| {case['source_version']} | {policy['target_version']} | {case['backend']} | {case['configuration']} |" for case in policy["cases"] ) + if automatic := policy["automatic_sources"]: + lines.extend( + f"| stable >= {automatic['minimum_version']}, < target | {policy['target_version']} | {profile['backend']} | {profile['configuration']} |" + for profile in automatic["profiles"] + ) return "\n".join(lines) @@ -232,7 +260,11 @@ def run_case(root: Path, wheel: Path, case: dict[str, Any], output: Path) -> dic directory = Path(temporary) extras = "cli,server,seekdb" if case["backend"] == "seekdb" else "cli,server" interpreters = {} - packages = {"source": f"powercontext[{extras}]=={case['source_version']}", "target": f"{wheel}[{extras}]"} + artifact = case["source_artifact"] + packages = { + "source": f"powercontext[{extras}] @ {artifact['url']}#sha256={artifact['sha256']}", + "target": f"{wheel}[{extras}]", + } for role, package in packages.items(): venv = directory / role run( @@ -255,6 +287,7 @@ def run_case(root: Path, wheel: Path, case: dict[str, Any], output: Path) -> dic environment.update({ "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", "PC_UPGRADE_SOURCE_PYTHON": str(interpreters["source"]), + "PC_UPGRADE_SOURCE_VERSION": case["source_version"], "PC_UPGRADE_TARGET_PYTHON": str(interpreters["target"]), "PC_UPGRADE_BACKEND": case["backend"], "PC_UPGRADE_CONFIGURATION": case["configuration"], @@ -309,7 +342,9 @@ def gate(root: Path, wheel: Path, output: Path, *, release: bool = False, releas "status": "failed", } try: - for index, case in enumerate(policy["cases"]): + matrix = resolve_matrix(policy, actual_version) + evidence["resolved_matrix"] = matrix + for index, case in enumerate(matrix["required_cases"]): evidence["cases"].append(run_case(root, wheel, case, output / f"case-{index}")) evidence["status"] = "passed" finally: @@ -338,7 +373,7 @@ def main() -> int: ) else: require(not args.release, "--release requires the actual release wheel") - print(f"Upgrade policy: {policy['mode']}; {len(policy['cases'])} supported combinations") + print(f"Upgrade policy: {policy['mode']}; source/backend combinations are resolved against the target wheel") except (GateError, OSError, ValueError, subprocess.SubprocessError, ElementTree.ParseError) as error: print(f"Upgrade release gate failed: {error}", file=sys.stderr) return 1 diff --git a/scripts/upgrade_release_matrix.py b/scripts/upgrade_release_matrix.py new file mode 100644 index 0000000000..371535049c --- /dev/null +++ b/scripts/upgrade_release_matrix.py @@ -0,0 +1,105 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Resolve a release support window into immutable source wheels and backend cases.""" + +from __future__ import annotations + +import hashlib +import json +import re +import urllib.request +from typing import Any +from urllib.parse import urlsplit + +from packaging.version import Version + +CATALOG_URL = "https://pypi.org/pypi/powercontext/json" +STABLE = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" + + +def fetch_catalog() -> dict[str, Any]: + # A discovery outage must fail closed, never reduce the matrix to zero cases. + with urllib.request.urlopen(CATALOG_URL, timeout=30) as response: # noqa: S310 -- fixed public PyPI endpoint + return json.load(response) + + +def source_artifact(catalog: dict[str, Any], version: str) -> dict[str, str]: + files = catalog["releases"].get(version, []) + wheels = [ + item + for item in files + if item.get("filename") == f"powercontext-{version}-py3-none-any.whl" + and item.get("packagetype") == "bdist_wheel" + and not item.get("yanked", False) + ] + if len(wheels) != 1: + raise ValueError(f"Required source {version} has no unique non-yanked universal wheel") # noqa: TRY003 + wheel = wheels[0] + url = urlsplit(wheel["url"]) + digest = wheel["digests"]["sha256"] + if ( + url.scheme != "https" + or url.netloc != "files.pythonhosted.org" + or url.fragment + or not re.fullmatch(r"[0-9a-f]{64}", digest) + ): + raise ValueError(f"Invalid source artifact metadata for {version}") # noqa: TRY003 + return {"filename": wheel["filename"], "url": wheel["url"], "sha256": digest} + + +def resolve_matrix(policy: dict[str, Any], target: str) -> dict[str, Any]: + automatic = policy["automatic_sources"] + explicit = policy["cases"] + if automatic is None and not explicit: + return {"candidate_cases": [], "required_cases": []} + catalog = fetch_catalog() + if not isinstance(catalog.get("releases"), dict) or not catalog["releases"]: + raise ValueError("Empty or malformed PyPI release catalog") # noqa: TRY003 + cases = [dict(case) for case in explicit] + if automatic: + minimum = Version(automatic["minimum_version"]) + versions = sorted( + ( + version + for version in catalog["releases"] + if re.fullmatch(STABLE, version) and minimum <= Version(version) < Version(target) + ), + key=Version, + ) + # Requiring the lower bound also detects a truncated catalog instead of + # silently dropping an older version that remains in the support promise. + if automatic["minimum_version"] not in versions: + raise ValueError("Automatic source window has no published minimum version below the target") # noqa: TRY003 + cases.extend( + {"source_version": version, **profile} for version in versions for profile in automatic["profiles"] + ) + identities = set() + for case in cases: + version = case["source_version"] + if Version(version) >= Version(target): + raise ValueError(f"Source {version} must precede target {target}") # noqa: TRY003 + identity = (version, case["backend"], case["configuration"]) + if identity in identities: + raise ValueError(f"Duplicate resolved upgrade case: {identity}") # noqa: TRY003 + identities.add(identity) + case["source_artifact"] = source_artifact(catalog, version) + return { + "catalog_url": CATALOG_URL, + "catalog_sha256": hashlib.sha256(json.dumps(catalog, sort_keys=True).encode()).hexdigest(), + "candidate_cases": cases, + # Fresh-install-only records discovery, never reports unexecuted upgrades + # as accepted. Switching mode requires real suites for every profile. + "required_cases": cases if policy["mode"] == "supported-upgrades" else [], + } diff --git a/tests/release_upgrades/README.md b/tests/release_upgrades/README.md index 65ebec7430..16266ab711 100644 --- a/tests/release_upgrades/README.md +++ b/tests/release_upgrades/README.md @@ -1,13 +1,39 @@ # Installed-release upgrade acceptance `release/upgrade-support.json` is the explicit upgrade support policy. Currently it declares -`fresh-install-only`: **no production database upgrade combination is supported**. An empty matrix in this mode -does not prove a migration works. Full migration integration is tracked in #2154. +`fresh-install-only`: **no production database upgrade combination is supported**. Discovered candidates in this mode +are not accepted upgrades. Full migration integration is tracked in #2154. -To advertise an upgrade, add a reviewed case with `source_version` (an exact published package version), `backend` -(`sqlite`, `seekdb`, or `oceanbase`), a stable `configuration` identifier, and a `suite` path under this directory. -Set `mode` to `supported-upgrades`. Optional features that change persistent objects need explicit cases/configurations. -No ranges, moving refs, silent backend fallback, or implicit combinations are supported. +## Automatic source versions + +The default `automatic_sources` policy uses `all-stable-since`, with `minimum_version: "1.3.0"` and SQLite/seekDB +backend profiles. Every gate run reads the public PowerContext PyPI release catalog and selects all stable `X.Y.Z` +versions at or above the minimum and strictly below the **actual target wheel version**. It excludes prereleases, +the target itself, and newer versions. The next run automatically includes newly published stable source versions; +no new test file or per-version matrix entry is needed. The target is the local build, so it need not be on PyPI yet. + +Each selected source is expanded across every configured backend/profile. A missing or yanked required wheel, +discovery outage, missing minimum version, or empty source window fails the gate instead of reducing coverage. +Source wheels are installed by their discovered public URL and SHA-256. The evidence records the catalog digest, +exact selected artifacts, and expanded matrix. Unrelated package indexes cannot substitute a source wheel. + +For example, with a minimum of `1.4.0`, a target of `1.4.1` tests `1.4.0`; after `1.4.1` is published, target `1.5.0` +tests both `1.4.0` and `1.4.1` on every backend. Other stable patches in the same interval are included too. This is +a promise to test **all** versions in the configured window, not just the latest patch. Change the minimum only +through a reviewed change to the support policy. The resolver currently requires universal `py3-none-any` wheels; +a future distribution format change must extend it explicitly, not silently omit a release. + +Currently the profiles have `suite: null` and the mode remains `fresh-install-only`. Discovery records candidate +cases, but executes and accepts **zero** positive upgrades. To activate supported upgrades, implement the complete +suite contract below for every profile, replace every null with a real suite path, set `mode` to `supported-upgrades`, +and regenerate the installation notices. The gate rejects supported mode with a missing suite. Future source releases +then use those same suites, with version adapters added where historical public interfaces differ. Tests must fail +when migration support is missing; the runner never changes expected success into a rejection automatically. + +`cases` remains available for exact source versions/configurations outside the automatic profiles. Each case needs +`source_version`, `backend`, `configuration`, and `suite`. Overlapping resolved cases fail rather than running twice. +Optional features that create persistent objects require additional backend profiles. OceanBase can be included by +adding its real environment/suite; embedded seekDB evidence does not establish OceanBase compatibility. The runner creates separate source/target environments, installs the exact published source package and the built target wheel, verifies their versions, then executes the reviewed suite in an isolated working directory. Repository @@ -17,6 +43,7 @@ processes, not import the current checkout's runtime in place of either version. The suite receives: - `PC_UPGRADE_SOURCE_PYTHON`, `PC_UPGRADE_TARGET_PYTHON`: isolated installed interpreters. +- `PC_UPGRADE_SOURCE_VERSION`: the exact source release selected for this case. - `PC_UPGRADE_BACKEND`, `PC_UPGRADE_CONFIGURATION`: the declared case. - `PC_UPGRADE_WORK`: disposable workspace for both databases, controlled inference fixtures, and environment files. - `PC_UPGRADE_EVIDENCE`: destination for backend evidence JSON. @@ -50,18 +77,20 @@ Test names and metadata do not themselves prove correctness: reviewers must insp they follow the public operations and compare actual persisted data. This contract is intended to host the real upgrade acceptance added with #2154, without claiming that it already exists. -For example, after implementing and reviewing an actual suite, a case would have this shape: +For example, after implementing and reviewing actual suites, automatic selection would have this shape: ```json { - "source_version": "1.3.0", - "backend": "sqlite", - "configuration": "full-server-no-vector", - "suite": "tests/release_upgrades/test_sqlite_130.py" + "strategy": "all-stable-since", + "minimum_version": "1.3.0", + "profiles": [ + {"backend": "sqlite", "configuration": "full-server-no-vector", "suite": "tests/release_upgrades/test_sqlite.py"}, + {"backend": "seekdb", "configuration": "full-server-no-vector", "suite": "tests/release_upgrades/test_seekdb.py"} + ] } ``` -This example is not an existing supported case. Regenerate installation support regions with +These suite paths illustrate the contract; they do not exist yet. Regenerate installation support regions with `uv run python scripts/upgrade_release_gate.py --write-docs` and run `make upgrade-policy-check`. When persistence inputs change, calculate `managed_digest(ROOT)` from `upgrade_release_gate`, review the compatibility impact, and update both the digest and rationale in `schema_review`. The inputs include persistence, optional code storage, @@ -77,3 +106,20 @@ against the GitHub event's notes and gates both PyPI publication and release ass Repository administrators must require **Upgrade release gate** in branch protection. Workflow code cannot configure that setting. Because the release workflow starts on `release.published`, it can block package/asset publication, not the earlier manual creation of a GitHub Release; run the prepublication command before publishing that page. + +## Actual historical rejection regressions + +The PR/master job runs `ci_legacy_upgrade_rejection.py` for **both SQLite and embedded seekDB** against a database +created and populated by installed PowerContext 1.3.0. It uses separate source/target environments, one disposable +database directory, sequential process ownership, and public HTTP writes/readback before testing target CLI behavior. +The installed drivers inventory the actual tables and record each environment's engine, driver, and binding versions. +No external seekDB service or credentials are needed. A missing embedded binding fails the job; it is never skipped. + +The regressions require historical startup rejection, full-baseline migration rejection, and rejection after adding +only the prototype revision table to the disposable fixture. The last operation is deliberately **not a migration**. +SeekDB permits either the known CHECK-reflection rejection or unknown-baseline rejection, so fixing #2160 does not +require retaining the parser defect. These tests do not certify that an upgrade succeeds. + +CI invokes them with `--respect-policy` after the main gate. Once supported-path acceptance is activated, these +historical rejection assertions are recorded as `not-applicable` instead of failing because migration was fixed. +The preceding positive gate must still execute all required version/backend cases and accept no skips. diff --git a/tests/test_upgrade_release_gate.py b/tests/test_upgrade_release_gate.py index 2909bdbcba..80bc9b4931 100644 --- a/tests/test_upgrade_release_gate.py +++ b/tests/test_upgrade_release_gate.py @@ -21,11 +21,12 @@ import pytest import upgrade_release_gate as gate +import upgrade_release_matrix as matrix import yaml @pytest.fixture -def release(tmp_path): +def release(tmp_path, monkeypatch): persistence = tmp_path / "src/powercontext/builtin/persistence/tables.py" persistence.parent.mkdir(parents=True) persistence.write_text("# disposable persistence input\n") @@ -34,13 +35,30 @@ def release(tmp_path): contract.write_text("info:\n version: 2.0.0\n") (tmp_path / "release").mkdir() policy = { - "format": 1, + "format": 2, "target_version": "2.0.0", "mode": "fresh-install-only", "schema_review": {"persistence_sha256": gate.managed_digest(tmp_path), "rationale": "No upgrades supported"}, "cases": [], + "automatic_sources": None, } (tmp_path / gate.MANIFEST).write_text(json.dumps(policy)) + monkeypatch.setattr( + matrix, + "fetch_catalog", + lambda: { + "releases": { + "1.3.0": [ + { + "filename": "powercontext-1.3.0-py3-none-any.whl", + "packagetype": "bdist_wheel", + "url": "https://files.pythonhosted.org/packages/test.whl", + "digests": {"sha256": "a" * 64}, + } + ] + } + }, + ) for language in ("en", "zh"): page = tmp_path / f"docs/{language}/docs/get-started/install-and-run.md" page.parent.mkdir(parents=True) @@ -196,6 +214,142 @@ def test_old_evidence_directory_cannot_be_reused(release): gate.gate(root, wheel, root / "evidence") +def catalog_for(*versions): + return { + "releases": { + version: [ + { + "filename": f"powercontext-{version}-py3-none-any.whl", + "packagetype": "bdist_wheel", + "yanked": False, + "url": f"https://files.pythonhosted.org/packages/powercontext-{version}-py3-none-any.whl", + "digests": {"sha256": "a" * 64}, + } + ] + for version in versions + } + } + + +def automatic_policy(policy, *, minimum="1.4.0"): + policy["automatic_sources"] = { + "strategy": "all-stable-since", + "minimum_version": minimum, + "profiles": [ + {"backend": backend, "configuration": "full-server-no-vector", "suite": None} + for backend in ("sqlite", "seekdb") + ], + } + + +@pytest.mark.parametrize( + "target,expected", + [ + ("1.4.1", ["1.4.0"]), + ("1.5.0", ["1.4.0", "1.4.1"]), + ("1.5.1", ["1.4.0", "1.4.1", "1.5.0"]), + ("1.5.1.dev2", ["1.4.0", "1.4.1", "1.5.0"]), + ], +) +def test_release_window_expands_for_each_target_without_new_test_scripts(release, monkeypatch, target, expected): + _, _, policy = release + automatic_policy(policy) + monkeypatch.setattr( + matrix, "fetch_catalog", lambda: catalog_for("1.3.0", "1.4.0", "1.4.1", "1.5.0rc1", "1.5.0", "1.5.1", "1.6.0") + ) + resolved = matrix.resolve_matrix(policy, target) + assert [(case["source_version"], case["backend"]) for case in resolved["candidate_cases"]] == [ + (version, backend) for version in expected for backend in ("sqlite", "seekdb") + ] + assert resolved["required_cases"] == [] # Discovery does not establish upgrade support. + assert all(case["source_artifact"]["sha256"] == "a" * 64 for case in resolved["candidate_cases"]) + + +def test_newly_published_source_is_automatically_added_on_next_run(release, monkeypatch): + _, _, policy = release + automatic_policy(policy) + catalog = catalog_for("1.4.0") + monkeypatch.setattr(matrix, "fetch_catalog", lambda: catalog) + first = matrix.resolve_matrix(policy, "1.5.0") + catalog["releases"].update(catalog_for("1.4.1")["releases"]) + second = matrix.resolve_matrix(policy, "1.5.0") + assert len(first["candidate_cases"]) == 2 + assert len(second["candidate_cases"]) == 4 + assert first["catalog_sha256"] != second["catalog_sha256"] + + +@pytest.mark.parametrize( + "broken", ["empty", "missing-minimum", "yanked", "missing-wheel", "bad-hash", "foreign-origin"] +) +def test_unavailable_source_cannot_silently_shrink_matrix(release, monkeypatch, broken): + _, _, policy = release + automatic_policy(policy) + catalog = catalog_for("1.4.0", "1.4.1") + wheel = catalog["releases"]["1.4.0"][0] + if broken == "empty": + catalog["releases"] = {} + elif broken == "missing-minimum": + del catalog["releases"]["1.4.0"] + elif broken == "yanked": + wheel["yanked"] = True + elif broken == "missing-wheel": + catalog["releases"]["1.4.1"] = [] + elif broken == "bad-hash": + wheel["digests"]["sha256"] = "invalid" + else: + wheel["url"] = "https://untrusted.invalid/package.whl" + monkeypatch.setattr(matrix, "fetch_catalog", lambda: catalog) + with pytest.raises(ValueError): + matrix.resolve_matrix(policy, "1.5.0") + + +def test_catalog_outage_records_a_failed_gate(release, monkeypatch): + root, wheel, policy = release + automatic_policy(policy) + (root / gate.MANIFEST).write_text(json.dumps(policy)) + + def unavailable(): + raise OSError("Catalog unavailable") # noqa: TRY003 -- exercise a distinct discovery outage + + monkeypatch.setattr(matrix, "fetch_catalog", unavailable) + with pytest.raises(OSError, match="Catalog unavailable"): + gate.gate(root, wheel, root / "evidence") + assert json.loads((root / "evidence/result.json").read_text())["status"] == "failed" + + +def test_switching_to_supported_upgrades_requires_all_backend_suites(release): + root, _, policy = release + automatic_policy(policy) + policy["mode"] = "supported-upgrades" + (root / gate.MANIFEST).write_text(json.dumps(policy)) + with pytest.raises(gate.GateError, match="every backend profile"): + gate.load_policy(root) + + +def test_supported_window_executes_every_discovered_version_on_every_backend(release, monkeypatch): + root, wheel, policy = release + supported_case(root, policy) + suite = policy["cases"][0]["suite"] + policy["cases"] = [] + automatic_policy(policy) + for profile in policy["automatic_sources"]["profiles"]: + profile["suite"] = suite + (root / gate.MANIFEST).write_text(json.dumps(policy)) + gate.check_docs(root, policy, write=True) + monkeypatch.setattr(matrix, "fetch_catalog", lambda: catalog_for("1.4.0", "1.4.1")) + executed = [] + + def execute(_root, _wheel, case, _output): + executed.append((case["source_version"], case["backend"])) + return {"case": case, "status": "passed"} + + monkeypatch.setattr(gate, "run_case", execute) + gate.gate(root, wheel, root / "evidence") + assert executed == [(version, backend) for version in ("1.4.0", "1.4.1") for backend in ("sqlite", "seekdb")] + result = json.loads((root / "evidence/result.json").read_text()) + assert len(result["cases"]) == len(result["resolved_matrix"]["required_cases"]) == 4 + + def test_publishing_jobs_depend_on_nonoptional_preupload_upgrade_gate(): root = Path(__file__).parents[1] workflow = yaml.safe_load((root / ".github/workflows/release.yml").read_text()) From 2be07394bac23f132c290ffb8bde5aeea1d98da1 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Thu, 22 Oct 2026 11:26:31 +0800 Subject: [PATCH 03/11] ci(release): pin upgrade baseline and add OceanBase coverage --- .../setup-upgrade-oceanbase/action.yml | 62 ++++++++++++ .github/workflows/master.yml | 20 +++- .github/workflows/release.yml | 25 +++++ docs/en/development/releasing.md | 15 +-- docs/zh/development/releasing.md | 16 +-- release/upgrade-support.json | 9 +- scripts/ci_legacy_upgrade_rejection.py | 41 ++++++-- scripts/upgrade_database_probe.py | 38 ++++++-- scripts/upgrade_release_gate.py | 10 +- scripts/upgrade_release_matrix.py | 19 +--- tests/release_upgrades/README.md | 81 ++++++++-------- tests/test_upgrade_release_gate.py | 97 ++++++++++++------- 12 files changed, 305 insertions(+), 128 deletions(-) create mode 100644 .github/actions/setup-upgrade-oceanbase/action.yml diff --git a/.github/actions/setup-upgrade-oceanbase/action.yml b/.github/actions/setup-upgrade-oceanbase/action.yml new file mode 100644 index 0000000000..949b649610 --- /dev/null +++ b/.github/actions/setup-upgrade-oceanbase/action.yml @@ -0,0 +1,62 @@ +name: Setup upgrade OceanBase +description: Start the disposable MySQL tenant used by installed-release upgrade checks +runs: + using: composite + steps: + - name: Start and verify OceanBase + shell: bash + env: + OCEANBASE_IMAGE: ghcr.io/oceanbase/oceanbase-ce:4.3.5.6-106000012026040916 + run: | + docker run --detach --name upgrade-oceanbase \ + --publish 127.0.0.1:2881:2881 \ + --ulimit nofile=65536:65536 \ + --env MODE=slim \ + --env OB_DATABASE=pc_upgrade_test \ + --env OB_TENANT_PASSWORD=powercontext-e2e \ + "$OCEANBASE_IMAGE" + for attempt in $(seq 1 120); do + if docker exec upgrade-oceanbase \ + obclient -h127.0.0.1 -P2881 -uroot@test -ppowercontext-e2e \ + -Dpc_upgrade_test -e 'SELECT 1' >/dev/null 2>&1; then + # The slim image's default 1 GB maximum is too small for the + # indexes created across the clean migration acceptance cases. + docker exec upgrade-oceanbase \ + obclient -h127.0.0.1 -P2881 -uroot@sys \ + -e "ALTER SYSTEM SET datafile_maxsize='8G'; ALTER SYSTEM SET datafile_next='512M'; ALTER SYSTEM SET datafile_size='4G';" + datafile_ready=false + for config_attempt in $(seq 1 30); do + docker exec upgrade-oceanbase \ + obclient -h127.0.0.1 -P2881 -uroot@sys --batch --skip-column-names \ + -e "SHOW PARAMETERS WHERE name IN ('datafile_size', 'datafile_maxsize', 'datafile_next');" \ + > "$RUNNER_TEMP/upgrade-datafile.tsv" + if awk ' + $5 == "datafile_size" && $7 == "4G" { size = 1 } + $5 == "datafile_maxsize" && $7 == "8G" { maximum = 1 } + $5 == "datafile_next" && $7 == "512M" { increment = 1 } + END { exit !(size && maximum && increment) } + ' "$RUNNER_TEMP/upgrade-datafile.tsv"; then + datafile_ready=true + break + fi + sleep 2 + done + cat "$RUNNER_TEMP/upgrade-datafile.tsv" + if [ "$datafile_ready" != true ]; then + echo 'OceanBase datafile configuration did not become effective within one minute.' >&2 + exit 1 + fi + docker exec upgrade-oceanbase \ + obclient -h127.0.0.1 -P2881 -uroot@test -ppowercontext-e2e \ + -Dpc_upgrade_test \ + -e "CREATE DATABASE pc_upgrade_fresh; SET GLOBAL recyclebin=OFF; SELECT VERSION(); SHOW VARIABLES LIKE 'ob_compatibility_mode'; SHOW VARIABLES LIKE 'recyclebin';" + exit 0 + fi + if [ "$(docker inspect --format '{{.State.Running}}' upgrade-oceanbase)" != true ]; then + echo 'OceanBase exited before becoming ready.' >&2 + exit 1 + fi + sleep 5 + done + echo 'OceanBase did not become ready within 10 minutes.' >&2 + exit 1 diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index c6489517a7..d45eb86a9a 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -23,6 +23,9 @@ jobs: name: Upgrade release gate runs-on: ubuntu-24.04 timeout-minutes: 90 + env: + PC_UPGRADE_OCEANBASE_URL: mysql+aoceanbase://root%40test:powercontext-e2e@127.0.0.1:2881/pc_upgrade_test?charset=utf8mb4 + PC_UPGRADE_OCEANBASE_FRESH_URL: mysql+aoceanbase://root%40test:powercontext-e2e@127.0.0.1:2881/pc_upgrade_fresh?charset=utf8mb4 steps: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -37,18 +40,31 @@ jobs: uv run pytest -q tests/test_upgrade_release_gate.py - name: Build the target artifact run: uv build --wheel --out-dir dist + - name: Start the isolated upgrade OceanBase environment + uses: ./.github/actions/setup-upgrade-oceanbase + - name: Run declared installed-release acceptance run: >- uv run python scripts/run_upgrade_release_gate.py --output "$RUNNER_TEMP/upgrade-release-evidence" - - name: Reproduce unsupported SQLite and embedded seekDB upgrades + - name: Reproduce unsupported upgrades on all three backends run: | wheel=$(find dist -name '*.whl' -print -quit) - for backend in sqlite seekdb; do + for backend in sqlite seekdb oceanbase; do uv run python scripts/ci_legacy_upgrade_rejection.py \ --backend "$backend" --respect-policy --wheel "$wheel" \ --output "$RUNNER_TEMP/upgrade-release-evidence/legacy-$backend" done + - name: Preserve OceanBase diagnostics + if: always() + run: | + mkdir -p "$RUNNER_TEMP/upgrade-release-evidence" + docker logs upgrade-oceanbase > "$RUNNER_TEMP/upgrade-release-evidence/oceanbase.log" 2>&1 || true + docker inspect --format '{{json .State}}' upgrade-oceanbase \ + > "$RUNNER_TEMP/upgrade-release-evidence/oceanbase-state.json" 2>&1 || true + - name: Stop the isolated upgrade OceanBase environment + if: always() + run: docker rm --force upgrade-oceanbase - name: Preserve upgrade acceptance evidence if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d21ccd47ea..2dd1952db7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,9 @@ jobs: release-build: runs-on: ubuntu-24.04 timeout-minutes: 90 + env: + PC_UPGRADE_OCEANBASE_URL: mysql+aoceanbase://root%40test:powercontext-e2e@127.0.0.1:2881/pc_upgrade_test?charset=utf8mb4 + PC_UPGRADE_OCEANBASE_FRESH_URL: mysql+aoceanbase://root%40test:powercontext-e2e@127.0.0.1:2881/pc_upgrade_fresh?charset=utf8mb4 steps: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -74,12 +77,34 @@ jobs: uv build --out-dir dist uvx --from twine twine check dist/* + - name: Start the isolated upgrade OceanBase environment + uses: ./.github/actions/setup-upgrade-oceanbase + - name: Gate upgrade claims on installed release acceptance run: >- uv run python scripts/run_upgrade_release_gate.py --release --release-event "$GITHUB_EVENT_PATH" --output "$RUNNER_TEMP/upgrade-release-evidence" + - name: Reproduce unsupported upgrades on all three backends + run: | + wheel=$(find dist -name '*.whl' -print -quit) + for backend in sqlite seekdb oceanbase; do + uv run python scripts/ci_legacy_upgrade_rejection.py \ + --backend "$backend" --respect-policy --wheel "$wheel" \ + --output "$RUNNER_TEMP/upgrade-release-evidence/legacy-$backend" + done + - name: Preserve OceanBase diagnostics + if: always() + run: | + mkdir -p "$RUNNER_TEMP/upgrade-release-evidence" + docker logs upgrade-oceanbase > "$RUNNER_TEMP/upgrade-release-evidence/oceanbase.log" 2>&1 || true + docker inspect --format '{{json .State}}' upgrade-oceanbase \ + > "$RUNNER_TEMP/upgrade-release-evidence/oceanbase-state.json" 2>&1 || true + - name: Stop the isolated upgrade OceanBase environment + if: always() + run: docker rm --force upgrade-oceanbase + - name: Preserve upgrade acceptance evidence if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 diff --git a/docs/en/development/releasing.md b/docs/en/development/releasing.md index 878945cf10..4dff970068 100644 --- a/docs/en/development/releasing.md +++ b/docs/en/development/releasing.md @@ -130,9 +130,11 @@ installation pins are synchronized. ### Database upgrade support `release/upgrade-support.json` declares the target version, source-version selection policy, backends, and -configurations. `automatic_sources` selects every stable PyPI release from `minimum_version` up to, but excluding, -the actual target wheel. Newly published stable versions become source cases on subsequent runs without adding -per-version tests. The runner expands every source across all backend profiles and records source wheel SHA-256s. +configurations. `automatic_sources` pins `source_version: "1.3.0"` with the `fixed-version` strategy and expands it +across SQLite, embedded seekDB, and OceanBase. The target is the actual wheel built by CI. Future releases such as +1.4.1 and 1.5.0 continue testing from 1.3.0 automatically; intermediate releases are not added as sources. Source +wheel URLs and SHA-256s are resolved from public PyPI and recorded with the target artifact. This fixed baseline +alone does not certify databases created/populated by other releases; use explicit cases for additional claims. The current policy is `fresh-install-only`; existing Server database upgrades are unsupported. Its null suite paths record candidates only, not accepted upgrades. Supported mode requires implemented suites for every backend profile. Do not advertise a supported upgrade until its installed-release acceptance suite is implemented and passes. @@ -157,9 +159,10 @@ in separate environments. Missing tests, skipped backends, failures, and missing version; the `--release` check must run against the final tagged wheel without overriding its version. The **Upgrade release gate** CI job also reproduces the known unsupported upgrade rejections using real 1.3.0 -SQLite and embedded seekDB Server databases. It installs and starts seekDB inside the runner with a disposable -directory; no external service is needed. Once supported mode is activated, these negative assertions retire and -the full discovered upgrade matrix must pass. Repository administrators must make this job required in branch protection. +SQLite, embedded seekDB, and OceanBase Server databases. seekDB runs in a temporary runner directory. Both PR +and publication jobs start a disposable OceanBase CE container, wait for its MySQL tenant, archive diagnostics, and +remove it even on failure. No external database credentials are needed. Once supported mode is activated, these negative assertions retire and +the fixed-source three-backend upgrade matrix must pass. Repository administrators must make this job required in branch protection. The release workflow gates distribution upload, PyPI publication, and release assets on the acceptance result and archives its evidence. It runs after `release.published`, so it cannot prevent the earlier manual publication of the GitHub Release page: run the command above before that action. `make publish` requires diff --git a/docs/zh/development/releasing.md b/docs/zh/development/releasing.md index 760ea31da7..b538eabe88 100644 --- a/docs/zh/development/releasing.md +++ b/docs/zh/development/releasing.md @@ -122,9 +122,11 @@ DSH README 中说明某项能力已包含在 `1.1.0` 的文字同样属于历史 ### 数据库升级支持范围 -`release/upgrade-support.json` 声明目标版本、来源版本选择策略、后端和配置。`automatic_sources` 从 PyPI 自动选择 -大于等于 `minimum_version`、小于实际目标 wheel 的全部稳定版本,再与每个后端配置展开组合。新稳定版发布后, -后续运行会自动将其加入来源,不必逐版本新增测试。证据会记录具体来源 wheel 的 SHA-256。 +`release/upgrade-support.json` 声明目标版本、来源版本选择策略、后端和配置。`automatic_sources` 使用 +`fixed-version` 策略,将 `source_version` 固定为 `1.3.0`,展开 SQLite、嵌入式 seekDB 和 OceanBase 三种后端。 +目标始终是本次 CI 构建的实际 wheel;后续发布 1.4.1、1.5.0 时,自动测试 1.3.0 到本次版本,不增加中间版本来源。 +来源 wheel 的公开 PyPI 地址和 SHA-256 与目标产物一起记录。固定起点不能证明其他版本实际生成的数据同样兼容; +如需额外声明这些路径,使用显式 `cases` 补充验收。 当前策略为 `fresh-install-only`,不支持已有 Server 数据库升级;配置中的空套件路径只生成候选组合,不算验收通过。 启用支持升级模式时,每个后端配置都必须有真实套件。只有实现并通过已安装发行包的完整升级验收,才能声明支持 对应组合。验收契约和证据要求见仓库中的 `tests/release_upgrades/README.md`。 @@ -144,9 +146,11 @@ uv run python scripts/run_upgrade_release_gate.py --release --release-notes /pat 旧版发行包和目标 wheel。测试缺失、后端跳过、执行失败或证据缺失都会阻断检查。`fresh-install-only` 策略检查 通过不表示存在升级路径。普通 PR 构建允许使用开发版本;`--release` 必须验证最终 tag 构建的 wheel,不能覆盖版本号。 -CI 中的 **Upgrade release gate** 还会使用真实 1.3.0 SQLite 和嵌入式 seekDB Server 数据库复现已知的升级拒绝行为。 -seekDB 直接在 runner 的临时目录中启动,不需要外部服务。启用支持升级模式后,固定的拒绝断言退出,自动展开的 -全部成功升级组合必须通过验收。仓库管理员需要在分支保护中将该检查设为必需。 +CI 中的 **Upgrade release gate** 使用真实 1.3.0 SQLite、嵌入式 seekDB 和 OceanBase Server 数据库复现升级拒绝行为。 +seekDB 直接在 runner 临时目录启动;PR 和发布工作流均启动隔离的 OceanBase CE 容器,等待 MySQL 租户就绪, +归档诊断并在测试失败时也清理容器,不依赖外部数据库凭据。任一后端无法启动都使检查失败,不能跳过。 +启用支持升级模式后,固定的拒绝断言退出,1.3.0 到目标版本的三种后端完整升级验收必须通过。 +仓库管理员需要在分支保护中将该检查设为必需。 发布工作流只有验收通过才会上传发行包、发布 PyPI 和上传 Release 附件,并归档验收证据。工作流在 `release.published` 后运行,无法阻止之前手动发布 GitHub Release 页面,因此必须先运行上述命令再发布页面。 `make publish` 要求提供 `UPGRADE_RELEASE_NOTES=/path/to/release-notes.md`,并在上传软件包之前执行相同门禁。 diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 4e61ff6624..77d31c67eb 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -8,8 +8,8 @@ }, "cases": [], "automatic_sources": { - "strategy": "all-stable-since", - "minimum_version": "1.3.0", + "strategy": "fixed-version", + "source_version": "1.3.0", "profiles": [ { "backend": "sqlite", @@ -20,6 +20,11 @@ "backend": "seekdb", "configuration": "full-server-no-vector", "suite": null + }, + { + "backend": "oceanbase", + "configuration": "full-server-no-vector", + "suite": null } ] } diff --git a/scripts/ci_legacy_upgrade_rejection.py b/scripts/ci_legacy_upgrade_rejection.py index 7bedf3a5f2..ef9f9a46d1 100644 --- a/scripts/ci_legacy_upgrade_rejection.py +++ b/scripts/ci_legacy_upgrade_rejection.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Reproduce unsupported 1.3.0 upgrades on an actual SQLite or embedded seekDB Server. +"""Reproduce unsupported 1.3.0 upgrades on actual SQLite, embedded seekDB or OceanBase Servers. This is a negative regression, never positive upgrade acceptance. CI retires these assertions once the supported-path matrix becomes mandatory. @@ -26,6 +26,7 @@ import subprocess import tempfile from pathlib import Path +from urllib.parse import urlsplit import httpx from ci_release_smoke import _available_port, _stop_process, _wait_until_ready @@ -108,7 +109,7 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--wheel", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--backend", choices=("sqlite", "seekdb"), default="sqlite") + parser.add_argument("--backend", choices=("sqlite", "seekdb", "oceanbase"), default="sqlite") parser.add_argument( "--respect-policy", action="store_true", @@ -133,6 +134,14 @@ def main() -> None: ) print("Historical rejection assertions retired; the supported upgrade matrix must pass instead.") return + if args.backend == "oceanbase": + url = urlsplit(os.environ.get("PC_UPGRADE_OCEANBASE_URL", "")) + require( + url.scheme == "mysql+aoceanbase" + and url.hostname in {"localhost", "127.0.0.1"} + and url.path == "/pc_upgrade_test", + "OceanBase regression requires the dedicated local pc_upgrade_test database", + ) environment = { key: value for key, value in os.environ.items() @@ -164,17 +173,20 @@ def main() -> None: require(metadata["source"]["powercontext"] == "1.3.0", "Wrong source package") require(metadata["target"]["powercontext"] == wheel_version(wheel), "Wrong target package") database = directory / "database" - database_environment = ( - {"POWERCONTEXT_SERVER_DATABASE_KIND": "seekdb", "POWERCONTEXT_SERVER_DATABASE_PATH": str(database)} - if args.backend == "seekdb" - else {"POWERCONTEXT_SERVER_DATABASE_URL": f"sqlite+aiosqlite:///{database}"} - ) + if args.backend == "seekdb": + database_environment = { + "POWERCONTEXT_SERVER_DATABASE_KIND": "seekdb", + "POWERCONTEXT_SERVER_DATABASE_PATH": str(database), + } + elif args.backend == "oceanbase": + database_environment = {"POWERCONTEXT_SERVER_DATABASE_URL": environment["PC_UPGRADE_OCEANBASE_URL"]} + else: + database_environment = {"POWERCONTEXT_SERVER_DATABASE_URL": f"sqlite+aiosqlite:///{database}"} environment.update({ "POWERCONTEXT_HOME": str(directory / "home"), **database_environment, "POWERCONTEXT_SERVER_ACCESS_MODE": "disabled", }) - seed = source_database(interpreters["source"], directory, environment, output / "source.log") probe = [ "-I", str(ROOT / "scripts/upgrade_database_probe.py"), @@ -183,6 +195,17 @@ def main() -> None: "--database", str(database), ] + if args.backend == "oceanbase": + run( + [str(interpreters["source"]), *probe, "--output", str(output / "empty-schema.json")], + environment=environment, + cwd=directory, + log=output / "empty-probe.log", + ) + empty_schema = json.loads((output / "empty-schema.json").read_text()) + require(not empty_schema["tables"], "OceanBase test database must be empty; refusing to overwrite data") + require(empty_schema["tenant_mode"] == "mysql", "OceanBase MySQL tenant required") + seed = source_database(interpreters["source"], directory, environment, output / "source.log") for role, python in interpreters.items(): run( [str(python), *probe, "--output", str(output / f"{role}-schema.json")], @@ -205,7 +228,7 @@ def main() -> None: environment, directory, output / "status.log", - ("unknown_baseline", "incompatible_schema") if args.backend == "seekdb" else ("unknown_baseline",), + ("unknown_baseline", "incompatible_schema") if args.backend != "sqlite" else ("unknown_baseline",), ) rejected( [target, "server", "run", "--no-env-file", "--host", "127.0.0.1", "--port", str(_available_port())], diff --git a/scripts/upgrade_database_probe.py b/scripts/upgrade_database_probe.py index 8b8822979d..0188b77823 100644 --- a/scripts/upgrade_database_probe.py +++ b/scripts/upgrade_database_probe.py @@ -23,11 +23,13 @@ import argparse import asyncio import json +import os import sqlite3 from importlib import import_module from importlib.metadata import version from pathlib import Path from typing import Any +from urllib.parse import unquote, urlsplit REVISION_DDL = "CREATE TABLE pc_schema_revision (version_num VARCHAR(32) PRIMARY KEY)" @@ -42,18 +44,35 @@ async def inspect(backend: str, database: Path, *, add_revision: bool) -> dict[s # Use the driver's public API, not a current-checkout Profile that could run # startup guards or mutate an old schema before the target CLI is tested. - binding = import_module("pylibseekdb") pymysql = import_module("pymysql") - instance = await binding.aopen(str(database)) + instance = None + details = {} + if backend == "seekdb": + binding = import_module("pylibseekdb") + instance = await binding.aopen(str(database)) + options = {**instance.connection_options(), "database": "test"} + details["embedded_binding_version"] = version("pylibseekdb") + else: + url = urlsplit(os.environ["PC_UPGRADE_OCEANBASE_URL"]) + options = { + "host": url.hostname, + "port": url.port or 2881, + "user": unquote(url.username or ""), + "password": unquote(url.password or ""), + "database": url.path.removeprefix("/"), + "charset": "utf8mb4", + "connect_timeout": 15, + } try: - with ( - pymysql.connect(**instance.connection_options(), database="test", autocommit=True) as connection, - connection.cursor() as cursor, - ): + with pymysql.connect(**options, autocommit=True) as connection, connection.cursor() as cursor: if add_revision: cursor.execute(REVISION_DDL) cursor.execute("SELECT VERSION()") engine = str(cursor.fetchone()[0]) + if backend == "oceanbase": + cursor.execute("SHOW VARIABLES LIKE 'ob_compatibility_mode'") + details["tenant_mode"] = str(cursor.fetchone()[1]).lower() + details["proxy_version"] = "direct" cursor.execute("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'") names = sorted(row[0] for row in cursor.fetchall()) tables = {} @@ -63,16 +82,17 @@ async def inspect(backend: str, database: Path, *, add_revision: bool) -> dict[s return { "engine_version": engine, "driver_version": version("pymysql"), - "embedded_binding_version": version("pylibseekdb"), + **details, "tables": tables, } finally: - instance.close() + if instance is not None: + instance.close() def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--backend", choices=("sqlite", "seekdb"), required=True) + parser.add_argument("--backend", choices=("sqlite", "seekdb", "oceanbase"), required=True) parser.add_argument("--database", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--add-revision", action="store_true") diff --git a/scripts/upgrade_release_gate.py b/scripts/upgrade_release_gate.py index ba0178a88d..cf0a3cbc4d 100644 --- a/scripts/upgrade_release_gate.py +++ b/scripts/upgrade_release_gate.py @@ -114,11 +114,11 @@ def load_policy(root: Path) -> dict[str, Any]: automatic = policy["automatic_sources"] profiles = [] if automatic is not None: - require(set(automatic) == {"strategy", "minimum_version", "profiles"}, "Invalid automatic source policy") - require(automatic["strategy"] == "all-stable-since", "Unknown source selection strategy") + require(set(automatic) == {"strategy", "source_version", "profiles"}, "Invalid automatic source policy") + require(automatic["strategy"] == "fixed-version", "Unknown source selection strategy") require( - isinstance(automatic["minimum_version"], str) and re.fullmatch(STABLE, automatic["minimum_version"]), - "Minimum source must be a stable version", + isinstance(automatic["source_version"], str) and re.fullmatch(STABLE, automatic["source_version"]), + "Fixed source must be a stable version", ) profiles = automatic["profiles"] require(isinstance(profiles, list) and profiles, "Automatic sources require backend profiles") @@ -168,7 +168,7 @@ def support_text(policy: dict[str, Any], language: str) -> str: ) if automatic := policy["automatic_sources"]: lines.extend( - f"| stable >= {automatic['minimum_version']}, < target | {policy['target_version']} | {profile['backend']} | {profile['configuration']} |" + f"| {automatic['source_version']} | {policy['target_version']} | {profile['backend']} | {profile['configuration']} |" for profile in automatic["profiles"] ) return "\n".join(lines) diff --git a/scripts/upgrade_release_matrix.py b/scripts/upgrade_release_matrix.py index 371535049c..dbb56b33ce 100644 --- a/scripts/upgrade_release_matrix.py +++ b/scripts/upgrade_release_matrix.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Resolve a release support window into immutable source wheels and backend cases.""" +"""Resolve a fixed source release into immutable source wheels and backend cases.""" from __future__ import annotations @@ -69,22 +69,7 @@ def resolve_matrix(policy: dict[str, Any], target: str) -> dict[str, Any]: raise ValueError("Empty or malformed PyPI release catalog") # noqa: TRY003 cases = [dict(case) for case in explicit] if automatic: - minimum = Version(automatic["minimum_version"]) - versions = sorted( - ( - version - for version in catalog["releases"] - if re.fullmatch(STABLE, version) and minimum <= Version(version) < Version(target) - ), - key=Version, - ) - # Requiring the lower bound also detects a truncated catalog instead of - # silently dropping an older version that remains in the support promise. - if automatic["minimum_version"] not in versions: - raise ValueError("Automatic source window has no published minimum version below the target") # noqa: TRY003 - cases.extend( - {"source_version": version, **profile} for version in versions for profile in automatic["profiles"] - ) + cases.extend({"source_version": automatic["source_version"], **profile} for profile in automatic["profiles"]) identities = set() for case in cases: version = case["source_version"] diff --git a/tests/release_upgrades/README.md b/tests/release_upgrades/README.md index 16266ab711..80c785b266 100644 --- a/tests/release_upgrades/README.md +++ b/tests/release_upgrades/README.md @@ -4,36 +4,34 @@ `fresh-install-only`: **no production database upgrade combination is supported**. Discovered candidates in this mode are not accepted upgrades. Full migration integration is tracked in #2154. -## Automatic source versions - -The default `automatic_sources` policy uses `all-stable-since`, with `minimum_version: "1.3.0"` and SQLite/seekDB -backend profiles. Every gate run reads the public PowerContext PyPI release catalog and selects all stable `X.Y.Z` -versions at or above the minimum and strictly below the **actual target wheel version**. It excludes prereleases, -the target itself, and newer versions. The next run automatically includes newly published stable source versions; -no new test file or per-version matrix entry is needed. The target is the local build, so it need not be on PyPI yet. - -Each selected source is expanded across every configured backend/profile. A missing or yanked required wheel, -discovery outage, missing minimum version, or empty source window fails the gate instead of reducing coverage. -Source wheels are installed by their discovered public URL and SHA-256. The evidence records the catalog digest, -exact selected artifacts, and expanded matrix. Unrelated package indexes cannot substitute a source wheel. - -For example, with a minimum of `1.4.0`, a target of `1.4.1` tests `1.4.0`; after `1.4.1` is published, target `1.5.0` -tests both `1.4.0` and `1.4.1` on every backend. Other stable patches in the same interval are included too. This is -a promise to test **all** versions in the configured window, not just the latest patch. Change the minimum only -through a reviewed change to the support policy. The resolver currently requires universal `py3-none-any` wheels; -a future distribution format change must extend it explicitly, not silently omit a release. - -Currently the profiles have `suite: null` and the mode remains `fresh-install-only`. Discovery records candidate -cases, but executes and accepts **zero** positive upgrades. To activate supported upgrades, implement the complete -suite contract below for every profile, replace every null with a real suite path, set `mode` to `supported-upgrades`, -and regenerate the installation notices. The gate rejects supported mode with a missing suite. Future source releases -then use those same suites, with version adapters added where historical public interfaces differ. Tests must fail -when migration support is missing; the runner never changes expected success into a rejection automatically. - -`cases` remains available for exact source versions/configurations outside the automatic profiles. Each case needs -`source_version`, `backend`, `configuration`, and `suite`. Overlapping resolved cases fail rather than running twice. -Optional features that create persistent objects require additional backend profiles. OceanBase can be included by -adding its real environment/suite; embedded seekDB evidence does not establish OceanBase compatibility. +## Fixed source and current target + +The `automatic_sources` policy uses `strategy: "fixed-version"` and `source_version: "1.3.0"`, with SQLite, +embedded seekDB, and OceanBase profiles. Every run expands that exact source across the three backends. The target +is the **actual wheel built by this job**, not the latest published PyPI package. Thus releases 1.4.1 and 1.5.0 +exercise 1.3.0 → 1.4.1 and 1.3.0 → 1.5.0 without adding test scripts or changing the source version. Newly published +intermediate versions do not become additional sources. This fixed baseline does not establish support for every +intermediate release's database or data shapes; add explicit `cases` if those need separate acceptance. + +The runner resolves the exact source wheel from public PyPI and installs its URL with a SHA-256. Missing/yanked +source wheels, invalid artifact metadata, catalog outages, and a target at or below the source version fail the gate. +Evidence records the catalog digest, exact source artifact, target artifact digest, and expanded matrix. The resolver +requires universal `py3-none-any` wheels; a distribution format change must extend it explicitly. + +Currently the three profiles have `suite: null` and the mode remains `fresh-install-only`. Resolving candidates +executes and accepts **zero** positive upgrades. To activate supported upgrades, implement the complete suite +contract below for each backend, replace the null paths, set `mode` to `supported-upgrades`, and regenerate the +installation notices. A missing suite fails supported mode. Tests must fail when migration support is missing; +the runner never changes expected success into a rejection automatically. + +`cases` remains available for additional exact source versions/configurations. Each case needs `source_version`, +`backend`, `configuration`, and `suite`; overlapping cases fail. Optional features that create persistent objects +require additional profiles. The fixed CI baseline and advertised upgrade support must stay consistent. + +PR/master and publication CI start an isolated OceanBase CE container using the image and MySQL tenant setup from +the existing E2E workflow. They wait for SQL readiness and adequate datafile capacity before testing, save diagnostics, +and remove the container even when a check fails. No external database credentials are needed. Embedded seekDB +runs directly in a temporary runner directory. A backend startup failure fails the job instead of skipping it. The runner creates separate source/target environments, installs the exact published source package and the built target wheel, verifies their versions, then executes the reviewed suite in an isolated working directory. Repository @@ -47,6 +45,9 @@ The suite receives: - `PC_UPGRADE_BACKEND`, `PC_UPGRADE_CONFIGURATION`: the declared case. - `PC_UPGRADE_WORK`: disposable workspace for both databases, controlled inference fixtures, and environment files. - `PC_UPGRADE_EVIDENCE`: destination for backend evidence JSON. +- `PC_UPGRADE_OCEANBASE_URL`, `PC_UPGRADE_OCEANBASE_FRESH_URL`: dedicated disposable MySQL-tenant databases for + the upgraded and fresh-install comparisons. CI supplies both; local positive OceanBase suites must supply their + own isolated databases. Each suite must isolate/reset its own fixtures when multiple configurations run. Provide these behavioral tests (additional tests are allowed): @@ -81,11 +82,12 @@ For example, after implementing and reviewing actual suites, automatic selection ```json { - "strategy": "all-stable-since", - "minimum_version": "1.3.0", + "strategy": "fixed-version", + "source_version": "1.3.0", "profiles": [ {"backend": "sqlite", "configuration": "full-server-no-vector", "suite": "tests/release_upgrades/test_sqlite.py"}, - {"backend": "seekdb", "configuration": "full-server-no-vector", "suite": "tests/release_upgrades/test_seekdb.py"} + {"backend": "seekdb", "configuration": "full-server-no-vector", "suite": "tests/release_upgrades/test_seekdb.py"}, + {"backend": "oceanbase", "configuration": "full-server-no-vector", "suite": "tests/release_upgrades/test_oceanbase.py"} ] } ``` @@ -109,15 +111,18 @@ not the earlier manual creation of a GitHub Release; run the prepublication comm ## Actual historical rejection regressions -The PR/master job runs `ci_legacy_upgrade_rejection.py` for **both SQLite and embedded seekDB** against a database -created and populated by installed PowerContext 1.3.0. It uses separate source/target environments, one disposable -database directory, sequential process ownership, and public HTTP writes/readback before testing target CLI behavior. +The PR/master and publication jobs run `ci_legacy_upgrade_rejection.py` for **SQLite, embedded seekDB, and OceanBase** +against a database created and populated by installed PowerContext 1.3.0. It uses separate source/target environments, one disposable +database per backend, sequential process ownership, and public HTTP writes/readback before testing target CLI behavior. The installed drivers inventory the actual tables and record each environment's engine, driver, and binding versions. -No external seekDB service or credentials are needed. A missing embedded binding fails the job; it is never skipped. +OceanBase uses the container's dedicated `pc_upgrade_test` database and records its MySQL tenant mode and direct +connection. The negative runner only accepts the local test database and verifies it is empty before starting the +old Server; it refuses to overwrite existing data. No external service or credentials are needed. A missing embedded +binding or unavailable OceanBase instance fails the job; neither backend is skipped. The regressions require historical startup rejection, full-baseline migration rejection, and rejection after adding only the prototype revision table to the disposable fixture. The last operation is deliberately **not a migration**. -SeekDB permits either the known CHECK-reflection rejection or unknown-baseline rejection, so fixing #2160 does not +SeekDB and OceanBase permit either the known CHECK-reflection rejection or unknown-baseline rejection, so fixing #2160 does not require retaining the parser defect. These tests do not certify that an upgrade succeeds. CI invokes them with `--respect-policy` after the main gate. Once supported-path acceptance is activated, these diff --git a/tests/test_upgrade_release_gate.py b/tests/test_upgrade_release_gate.py index 80bc9b4931..e012b0a75d 100644 --- a/tests/test_upgrade_release_gate.py +++ b/tests/test_upgrade_release_gate.py @@ -231,69 +231,66 @@ def catalog_for(*versions): } -def automatic_policy(policy, *, minimum="1.4.0"): +def automatic_policy(policy): policy["automatic_sources"] = { - "strategy": "all-stable-since", - "minimum_version": minimum, + "strategy": "fixed-version", + "source_version": "1.3.0", "profiles": [ {"backend": backend, "configuration": "full-server-no-vector", "suite": None} - for backend in ("sqlite", "seekdb") + for backend in ("sqlite", "seekdb", "oceanbase") ], } -@pytest.mark.parametrize( - "target,expected", - [ - ("1.4.1", ["1.4.0"]), - ("1.5.0", ["1.4.0", "1.4.1"]), - ("1.5.1", ["1.4.0", "1.4.1", "1.5.0"]), - ("1.5.1.dev2", ["1.4.0", "1.4.1", "1.5.0"]), - ], -) -def test_release_window_expands_for_each_target_without_new_test_scripts(release, monkeypatch, target, expected): +@pytest.mark.parametrize("target", ["1.4.0", "1.4.1", "1.5.0", "1.5.1.dev2"]) +def test_fixed_source_tracks_each_target_on_all_three_backends(release, monkeypatch, target): _, _, policy = release automatic_policy(policy) monkeypatch.setattr( - matrix, "fetch_catalog", lambda: catalog_for("1.3.0", "1.4.0", "1.4.1", "1.5.0rc1", "1.5.0", "1.5.1", "1.6.0") + matrix, "fetch_catalog", lambda: catalog_for("1.3.0", "1.4.0", "1.4.1", "1.5.0rc1", "1.5.0", "1.6.0") ) resolved = matrix.resolve_matrix(policy, target) assert [(case["source_version"], case["backend"]) for case in resolved["candidate_cases"]] == [ - (version, backend) for version in expected for backend in ("sqlite", "seekdb") + ("1.3.0", backend) for backend in ("sqlite", "seekdb", "oceanbase") ] - assert resolved["required_cases"] == [] # Discovery does not establish upgrade support. + assert resolved["required_cases"] == [] # Candidate selection does not establish upgrade support. assert all(case["source_artifact"]["sha256"] == "a" * 64 for case in resolved["candidate_cases"]) -def test_newly_published_source_is_automatically_added_on_next_run(release, monkeypatch): +def test_newly_published_versions_do_not_change_the_fixed_source(release, monkeypatch): _, _, policy = release automatic_policy(policy) - catalog = catalog_for("1.4.0") + catalog = catalog_for("1.3.0") monkeypatch.setattr(matrix, "fetch_catalog", lambda: catalog) first = matrix.resolve_matrix(policy, "1.5.0") catalog["releases"].update(catalog_for("1.4.1")["releases"]) second = matrix.resolve_matrix(policy, "1.5.0") - assert len(first["candidate_cases"]) == 2 - assert len(second["candidate_cases"]) == 4 - assert first["catalog_sha256"] != second["catalog_sha256"] + assert first["candidate_cases"] == second["candidate_cases"] + assert len(second["candidate_cases"]) == 3 -@pytest.mark.parametrize( - "broken", ["empty", "missing-minimum", "yanked", "missing-wheel", "bad-hash", "foreign-origin"] -) +@pytest.mark.parametrize("target", ["1.2.0", "1.3.0", "1.3.0rc1"]) +def test_fixed_source_must_precede_target(release, target): + _, _, policy = release + automatic_policy(policy) + with pytest.raises(ValueError, match="must precede target"): + matrix.resolve_matrix(policy, target) + + +@pytest.mark.parametrize("broken", ["empty", "missing-source", "yanked", "missing-wheel", "bad-hash", "foreign-origin"]) def test_unavailable_source_cannot_silently_shrink_matrix(release, monkeypatch, broken): _, _, policy = release automatic_policy(policy) - catalog = catalog_for("1.4.0", "1.4.1") - wheel = catalog["releases"]["1.4.0"][0] + catalog = catalog_for("1.3.0", "1.4.1") + wheel = catalog["releases"]["1.3.0"][0] if broken == "empty": catalog["releases"] = {} - elif broken == "missing-minimum": - del catalog["releases"]["1.4.0"] + elif broken == "missing-source": + del catalog["releases"]["1.3.0"] elif broken == "yanked": wheel["yanked"] = True elif broken == "missing-wheel": - catalog["releases"]["1.4.1"] = [] + catalog["releases"]["1.3.0"] = [] elif broken == "bad-hash": wheel["digests"]["sha256"] = "invalid" else: @@ -326,7 +323,7 @@ def test_switching_to_supported_upgrades_requires_all_backend_suites(release): gate.load_policy(root) -def test_supported_window_executes_every_discovered_version_on_every_backend(release, monkeypatch): +def test_supported_fixed_source_executes_every_backend(release, monkeypatch): root, wheel, policy = release supported_case(root, policy) suite = policy["cases"][0]["suite"] @@ -336,7 +333,7 @@ def test_supported_window_executes_every_discovered_version_on_every_backend(rel profile["suite"] = suite (root / gate.MANIFEST).write_text(json.dumps(policy)) gate.check_docs(root, policy, write=True) - monkeypatch.setattr(matrix, "fetch_catalog", lambda: catalog_for("1.4.0", "1.4.1")) + monkeypatch.setattr(matrix, "fetch_catalog", lambda: catalog_for("1.3.0", "1.4.1")) executed = [] def execute(_root, _wheel, case, _output): @@ -345,9 +342,9 @@ def execute(_root, _wheel, case, _output): monkeypatch.setattr(gate, "run_case", execute) gate.gate(root, wheel, root / "evidence") - assert executed == [(version, backend) for version in ("1.4.0", "1.4.1") for backend in ("sqlite", "seekdb")] + assert executed == [(version, backend) for version in ("1.3.0",) for backend in ("sqlite", "seekdb", "oceanbase")] result = json.loads((root / "evidence/result.json").read_text()) - assert len(result["cases"]) == len(result["resolved_matrix"]["required_cases"]) == 4 + assert len(result["cases"]) == len(result["resolved_matrix"]["required_cases"]) == 3 def test_publishing_jobs_depend_on_nonoptional_preupload_upgrade_gate(): @@ -362,3 +359,35 @@ def test_publishing_jobs_depend_on_nonoptional_preupload_upgrade_gate(): assert index < upload for name in ("pypi-publish", "release-assets"): assert workflow["jobs"][name]["needs"] == "release-build" + + +def test_repository_baseline_covers_all_three_databases(): + policy = gate.load_policy(Path(__file__).parents[1]) + selection = policy["automatic_sources"] + assert selection["strategy"] == "fixed-version" + assert selection["source_version"] == "1.3.0" + assert {profile["backend"] for profile in selection["profiles"]} == {"sqlite", "seekdb", "oceanbase"} + + +@pytest.mark.parametrize("workflow_name,job_name", [("master", "upgrade-release-gate"), ("release", "release-build")]) +def test_upgrade_jobs_require_real_backends_and_always_clean_up(workflow_name, job_name): + root = Path(__file__).parents[1] + workflow = yaml.safe_load((root / f".github/workflows/{workflow_name}.yml").read_text()) + job = workflow["jobs"][job_name] + steps = job["steps"] + setup_index = next( + i for i, step in enumerate(steps) if step.get("uses") == "./.github/actions/setup-upgrade-oceanbase" + ) + gate_index = next(i for i, step in enumerate(steps) if "run_upgrade_release_gate.py" in step.get("run", "")) + regression_index = next( + i for i, step in enumerate(steps) if "ci_legacy_upgrade_rejection.py" in step.get("run", "") + ) + assert setup_index < gate_index < regression_index + for index in (setup_index, gate_index, regression_index): + assert "if" not in steps[index] and not steps[index].get("continue-on-error") + assert "for backend in sqlite seekdb oceanbase; do" in steps[regression_index]["run"] + assert "--respect-policy" in steps[regression_index]["run"] + assert "/pc_upgrade_test?" in job["env"]["PC_UPGRADE_OCEANBASE_URL"] + assert "/pc_upgrade_fresh?" in job["env"]["PC_UPGRADE_OCEANBASE_FRESH_URL"] + cleanup = next(step for step in steps if "docker rm --force upgrade-oceanbase" in step.get("run", "")) + assert cleanup["if"] == "always()" From 76d4863b4e3d4ec1b629460db842fd7fc960982b Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Thu, 22 Oct 2026 11:36:07 +0800 Subject: [PATCH 04/11] fix(ci): select OceanBase for the historical upgrade fixture --- scripts/ci_legacy_upgrade_rejection.py | 25 ++++++++++++++----------- tests/test_upgrade_release_gate.py | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/scripts/ci_legacy_upgrade_rejection.py b/scripts/ci_legacy_upgrade_rejection.py index ef9f9a46d1..4cbabf5332 100644 --- a/scripts/ci_legacy_upgrade_rejection.py +++ b/scripts/ci_legacy_upgrade_rejection.py @@ -37,6 +37,17 @@ def console(python: Path) -> Path: return python.with_name("powercontext.exe" if os.name == "nt" else "powercontext") +def database_environment(backend: str, database: Path, environment: dict[str, str]) -> dict[str, str]: + if backend == "seekdb": + return {"POWERCONTEXT_SERVER_DATABASE_KIND": "seekdb", "POWERCONTEXT_SERVER_DATABASE_PATH": str(database)} + if backend == "oceanbase": + return { + "POWERCONTEXT_SERVER_DATABASE_KIND": "oceanbase", + "POWERCONTEXT_SERVER_DATABASE_URL": environment["PC_UPGRADE_OCEANBASE_URL"], + } + return {"POWERCONTEXT_SERVER_DATABASE_URL": f"sqlite+aiosqlite:///{database}"} + + def source_database(python: Path, directory: Path, environment: dict[str, str], log: Path) -> dict[str, str | bool]: port = _available_port() base_url = f"http://127.0.0.1:{port}" @@ -173,18 +184,10 @@ def main() -> None: require(metadata["source"]["powercontext"] == "1.3.0", "Wrong source package") require(metadata["target"]["powercontext"] == wheel_version(wheel), "Wrong target package") database = directory / "database" - if args.backend == "seekdb": - database_environment = { - "POWERCONTEXT_SERVER_DATABASE_KIND": "seekdb", - "POWERCONTEXT_SERVER_DATABASE_PATH": str(database), - } - elif args.backend == "oceanbase": - database_environment = {"POWERCONTEXT_SERVER_DATABASE_URL": environment["PC_UPGRADE_OCEANBASE_URL"]} - else: - database_environment = {"POWERCONTEXT_SERVER_DATABASE_URL": f"sqlite+aiosqlite:///{database}"} + database_configuration = database_environment(args.backend, database, environment) environment.update({ "POWERCONTEXT_HOME": str(directory / "home"), - **database_environment, + **database_configuration, "POWERCONTEXT_SERVER_ACCESS_MODE": "disabled", }) probe = [ @@ -221,7 +224,7 @@ def main() -> None: "Source fixture lacks historical Server objects", ) env_file = directory / "deployment.env" - env_file.write_text("".join(f"{key}={value}\n" for key, value in database_environment.items())) + env_file.write_text("".join(f"{key}={value}\n" for key, value in database_configuration.items())) target = str(console(interpreters["target"])) rejected( [target, "server", "db-migrate", "status", "--env-file", str(env_file)], diff --git a/tests/test_upgrade_release_gate.py b/tests/test_upgrade_release_gate.py index e012b0a75d..28525694aa 100644 --- a/tests/test_upgrade_release_gate.py +++ b/tests/test_upgrade_release_gate.py @@ -15,6 +15,7 @@ """Release-blocking regressions, not evidence that any database upgrade works.""" import json +import os import zipfile from pathlib import Path from xml.etree import ElementTree @@ -23,6 +24,9 @@ import upgrade_release_gate as gate import upgrade_release_matrix as matrix import yaml +from ci_legacy_upgrade_rejection import database_environment + +from powercontext.server.settings import ServerSettings @pytest.fixture @@ -391,3 +395,15 @@ def test_upgrade_jobs_require_real_backends_and_always_clean_up(workflow_name, j assert "/pc_upgrade_fresh?" in job["env"]["PC_UPGRADE_OCEANBASE_FRESH_URL"] cleanup = next(step for step in steps if "docker rm --force upgrade-oceanbase" in step.get("run", "")) assert cleanup["if"] == "always()" + + +def test_oceanbase_fixture_selects_the_oceanbase_server_profile(tmp_path, monkeypatch): + for key in os.environ: + if key.startswith("POWERCONTEXT_"): + monkeypatch.delenv(key) + url = "mysql+aoceanbase://root%40test:test@127.0.0.1:2881/pc_upgrade_test?charset=utf8mb4" + for key, value in database_environment("oceanbase", tmp_path / "unused", {"PC_UPGRADE_OCEANBASE_URL": url}).items(): + monkeypatch.setenv(key, value) + settings = ServerSettings() + assert settings.database.kind == "oceanbase" + assert settings.database.url.get_secret_value() == url From 8391cf98e90e298c774afc6f679918197b4ba7c0 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Thu, 22 Oct 2026 11:42:39 +0800 Subject: [PATCH 05/11] fix(ci): provide OceanBase maintenance coordination options --- scripts/ci_legacy_upgrade_rejection.py | 5 ++++- tests/release_upgrades/README.md | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/ci_legacy_upgrade_rejection.py b/scripts/ci_legacy_upgrade_rejection.py index 4cbabf5332..d1b712ec00 100644 --- a/scripts/ci_legacy_upgrade_rejection.py +++ b/scripts/ci_legacy_upgrade_rejection.py @@ -226,8 +226,11 @@ def main() -> None: env_file = directory / "deployment.env" env_file.write_text("".join(f"{key}={value}\n" for key, value in database_configuration.items())) target = str(console(interpreters["target"])) + status_command = [target, "server", "db-migrate", "status", "--env-file", str(env_file)] + if args.backend == "oceanbase": + status_command.extend(["--evidence-dir", str(output / "maintenance"), "--lock-coordination", "single-host"]) rejected( - [target, "server", "db-migrate", "status", "--env-file", str(env_file)], + status_command, environment, directory, output / "status.log", diff --git a/tests/release_upgrades/README.md b/tests/release_upgrades/README.md index 80c785b266..4e7eddff93 100644 --- a/tests/release_upgrades/README.md +++ b/tests/release_upgrades/README.md @@ -116,7 +116,8 @@ against a database created and populated by installed PowerContext 1.3.0. It use database per backend, sequential process ownership, and public HTTP writes/readback before testing target CLI behavior. The installed drivers inventory the actual tables and record each environment's engine, driver, and binding versions. OceanBase uses the container's dedicated `pc_upgrade_test` database and records its MySQL tenant mode and direct -connection. The negative runner only accepts the local test database and verifies it is empty before starting the +connection. Its public migration status command supplies `--evidence-dir` and `--lock-coordination single-host`, +as required by OceanBase maintenance. The negative runner only accepts the local test database and verifies it is empty before starting the old Server; it refuses to overwrite existing data. No external service or credentials are needed. A missing embedded binding or unavailable OceanBase instance fails the job; neither backend is skipped. From fd4173d9fbd6e845f4ba50620266ec5ba560b6dd Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Thu, 22 Oct 2026 15:26:07 +0800 Subject: [PATCH 06/11] ci(release): require successful full database upgrades --- .github/workflows/master.yml | 8 - .github/workflows/release.yml | 8 - docs/en/development/releasing.md | 24 +- docs/en/docs/get-started/install-and-run.md | 2 +- docs/zh/development/releasing.md | 23 +- docs/zh/docs/get-started/install-and-run.md | 2 +- release/upgrade-support.json | 10 +- scripts/upgrade_database_probe.py | 20 +- scripts/upgrade_release_gate.py | 43 +- scripts/upgrade_release_matrix.py | 6 +- tests/conftest.py | 4 + tests/release_upgrades/README.md | 75 ++- tests/release_upgrades/processing_fixture.py | 60 +++ tests/release_upgrades/test_full_server.py | 465 +++++++++++++++++++ tests/test_release_version.py | 4 +- tests/test_upgrade_release_gate.py | 92 +++- 16 files changed, 714 insertions(+), 132 deletions(-) create mode 100644 tests/release_upgrades/processing_fixture.py create mode 100644 tests/release_upgrades/test_full_server.py diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index d45eb86a9a..eb4ac2e4a6 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -47,14 +47,6 @@ jobs: run: >- uv run python scripts/run_upgrade_release_gate.py --output "$RUNNER_TEMP/upgrade-release-evidence" - - name: Reproduce unsupported upgrades on all three backends - run: | - wheel=$(find dist -name '*.whl' -print -quit) - for backend in sqlite seekdb oceanbase; do - uv run python scripts/ci_legacy_upgrade_rejection.py \ - --backend "$backend" --respect-policy --wheel "$wheel" \ - --output "$RUNNER_TEMP/upgrade-release-evidence/legacy-$backend" - done - name: Preserve OceanBase diagnostics if: always() run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2dd1952db7..2dddbb90d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,14 +86,6 @@ jobs: --release --release-event "$GITHUB_EVENT_PATH" --output "$RUNNER_TEMP/upgrade-release-evidence" - - name: Reproduce unsupported upgrades on all three backends - run: | - wheel=$(find dist -name '*.whl' -print -quit) - for backend in sqlite seekdb oceanbase; do - uv run python scripts/ci_legacy_upgrade_rejection.py \ - --backend "$backend" --respect-policy --wheel "$wheel" \ - --output "$RUNNER_TEMP/upgrade-release-evidence/legacy-$backend" - done - name: Preserve OceanBase diagnostics if: always() run: | diff --git a/docs/en/development/releasing.md b/docs/en/development/releasing.md index 4dff970068..db765663cc 100644 --- a/docs/en/development/releasing.md +++ b/docs/en/development/releasing.md @@ -135,9 +135,11 @@ across SQLite, embedded seekDB, and OceanBase. The target is the actual wheel bu 1.4.1 and 1.5.0 continue testing from 1.3.0 automatically; intermediate releases are not added as sources. Source wheel URLs and SHA-256s are resolved from public PyPI and recorded with the target artifact. This fixed baseline alone does not certify databases created/populated by other releases; use explicit cases for additional claims. -The current policy is `fresh-install-only`; existing Server database upgrades are unsupported. Its null suite paths -record candidates only, not accepted upgrades. Supported mode requires implemented suites for every backend profile. -Do not advertise a supported upgrade until its installed-release acceptance suite is implemented and passes. +The current `upgrade-required` policy mandates positive acceptance on all three backends while keeping upgrade +support unverified. Every profile points to `tests/release_upgrades/test_full_server.py`; missing migration support +must fail. Neither a fresh-install-only declaration nor correct rejection of an old database satisfies this gate. +After the full migration implementation and acceptance pass, review the declaration, set `supported-upgrades`, and +regenerate the installation notices before publication. Both modes run the same mandatory acceptance matrix. The suite contract and evidence requirements are in `tests/release_upgrades/README.md` in the repository. `make check` and `make release-check` validate this policy and the bilingual installation notices. Changes to @@ -154,15 +156,17 @@ uv run python scripts/run_upgrade_release_gate.py --release --release-notes /pat The release check requires the actual wheel version to match the policy and the release notes to include the exact English support declaration from the installation guide. It installs each exact source release and the target wheel -in separate environments. Missing tests, skipped backends, failures, and missing evidence block the check. A passing -`fresh-install-only` policy check does not establish an upgrade path. Ordinary PR builds may have a development +in separate environments. Missing tests, skipped backends, failures, and missing evidence block the check. Static +policy validation alone does not establish an upgrade path. Ordinary PR builds may have a development version; the `--release` check must run against the final tagged wheel without overriding its version. -The **Upgrade release gate** CI job also reproduces the known unsupported upgrade rejections using real 1.3.0 -SQLite, embedded seekDB, and OceanBase Server databases. seekDB runs in a temporary runner directory. Both PR -and publication jobs start a disposable OceanBase CE container, wait for its MySQL tenant, archive diagnostics, and -remove it even on failure. No external database credentials are needed. Once supported mode is activated, these negative assertions retire and -the fixed-source three-backend upgrade matrix must pass. Repository administrators must make this job required in branch protection. +The **Upgrade release gate** CI job requires successful public migration commands, full Server readiness, target +startup, preserved records and new writes on real 1.3.0 databases. Limited bundle verification with +`server_ready=false` fails. Every backend runs even if another backend fails, with separate logs and results. +seekDB runs embedded in a temporary runner directory. PR and publication jobs start a disposable OceanBase CE +container, wait for its MySQL tenant, archive diagnostics, and remove it even on failure. No external database +credentials are needed. Unavailable backends fail instead of being skipped. Historical rejection regressions are +not part of the positive acceptance gate. Administrators must make this job required in branch protection. The release workflow gates distribution upload, PyPI publication, and release assets on the acceptance result and archives its evidence. It runs after `release.published`, so it cannot prevent the earlier manual publication of the GitHub Release page: run the command above before that action. `make publish` requires diff --git a/docs/en/docs/get-started/install-and-run.md b/docs/en/docs/get-started/install-and-run.md index f6021a91bd..b103a4c7cc 100644 --- a/docs/en/docs/get-started/install-and-run.md +++ b/docs/en/docs/get-started/install-and-run.md @@ -175,7 +175,7 @@ For a long-running process, Docker, authentication, or remote access, continue w ## Update or replace an installation -This release supports fresh installations only; upgrades of existing Server databases are not supported. +Upgrades from 1.3.0 require passing full Server acceptance on SQLite, seekDB and OceanBase. This migration path is not yet verified; do not upgrade existing databases. Keep existing deployments on their working version until their source version and backend have an accepted upgrade diff --git a/docs/zh/development/releasing.md b/docs/zh/development/releasing.md index b538eabe88..6c5af20b7d 100644 --- a/docs/zh/development/releasing.md +++ b/docs/zh/development/releasing.md @@ -127,9 +127,11 @@ DSH README 中说明某项能力已包含在 `1.1.0` 的文字同样属于历史 目标始终是本次 CI 构建的实际 wheel;后续发布 1.4.1、1.5.0 时,自动测试 1.3.0 到本次版本,不增加中间版本来源。 来源 wheel 的公开 PyPI 地址和 SHA-256 与目标产物一起记录。固定起点不能证明其他版本实际生成的数据同样兼容; 如需额外声明这些路径,使用显式 `cases` 补充验收。 -当前策略为 `fresh-install-only`,不支持已有 Server 数据库升级;配置中的空套件路径只生成候选组合,不算验收通过。 -启用支持升级模式时,每个后端配置都必须有真实套件。只有实现并通过已安装发行包的完整升级验收,才能声明支持 -对应组合。验收契约和证据要求见仓库中的 `tests/release_upgrades/README.md`。 +当前 `upgrade-required` 策略强制执行三种后端的正向升级验收,同时保持“升级支持尚未验证”的声明。 +所有配置均使用 `tests/release_upgrades/test_full_server.py`,迁移能力缺失必须失败。仅支持新建数据库的声明或 +正确拒绝旧库,都不能满足门禁。完整迁移实现和验收通过后,审查支持范围,将模式改为 `supported-upgrades`, +并在发布前重新生成安装说明;两种模式都运行相同的必测矩阵。 +验收契约和证据要求见仓库中的 `tests/release_upgrades/README.md`。 `make check` 和 `make release-check` 会核对策略及双语安装说明。受管持久化文件变化时,必须审查兼容性影响, 更新摘要和理由。摘要用于触发审查,本身不证明迁移正确。`make version-bump` 同时更新策略中的目标版本; @@ -143,13 +145,14 @@ uv run python scripts/run_upgrade_release_gate.py --release --release-notes /pat ``` 发布检查要求实际 wheel 版本与策略一致,发布说明包含安装指南中的完整英文支持声明。每个组合分别安装准确的 -旧版发行包和目标 wheel。测试缺失、后端跳过、执行失败或证据缺失都会阻断检查。`fresh-install-only` 策略检查 -通过不表示存在升级路径。普通 PR 构建允许使用开发版本;`--release` 必须验证最终 tag 构建的 wheel,不能覆盖版本号。 - -CI 中的 **Upgrade release gate** 使用真实 1.3.0 SQLite、嵌入式 seekDB 和 OceanBase Server 数据库复现升级拒绝行为。 -seekDB 直接在 runner 临时目录启动;PR 和发布工作流均启动隔离的 OceanBase CE 容器,等待 MySQL 租户就绪, -归档诊断并在测试失败时也清理容器,不依赖外部数据库凭据。任一后端无法启动都使检查失败,不能跳过。 -启用支持升级模式后,固定的拒绝断言退出,1.3.0 到目标版本的三种后端完整升级验收必须通过。 +旧版发行包和目标 wheel。测试缺失、后端跳过、执行失败或证据缺失都会阻断检查。仅通过静态策略检查不表示 +存在升级路径。普通 PR 构建允许使用开发版本;`--release` 必须验证最终 tag 构建的 wheel,不能覆盖版本号。 + +CI 中的 **Upgrade release gate** 要求真实 1.3.0 数据库通过公开命令迁移、完整 Server 就绪验证、目标版本启动、 +历史数据读取和新增写入。局部迁移验证返回 `server_ready=false` 也会失败。一个后端失败后,仍继续执行其他后端, +分别保存结果和日志。seekDB 直接在 runner 临时目录中以嵌入式启动;PR 和发布工作流均启动隔离的 OceanBase CE +容器,等待 MySQL 租户就绪,归档诊断并在测试失败时也清理容器,不依赖外部数据库凭据。 +任一后端无法启动都使检查失败,不能跳过。历史拒绝行为回归不参与正向升级门禁。 仓库管理员需要在分支保护中将该检查设为必需。 发布工作流只有验收通过才会上传发行包、发布 PyPI 和上传 Release 附件,并归档验收证据。工作流在 `release.published` 后运行,无法阻止之前手动发布 GitHub Release 页面,因此必须先运行上述命令再发布页面。 diff --git a/docs/zh/docs/get-started/install-and-run.md b/docs/zh/docs/get-started/install-and-run.md index 5e8360b924..becc71943d 100644 --- a/docs/zh/docs/get-started/install-and-run.md +++ b/docs/zh/docs/get-started/install-and-run.md @@ -161,7 +161,7 @@ Agent 诊断见[各自的集成文档](../integrations/index.md);Server 状态 ## 更新或替换安装 -此版本仅支持新建部署,尚不支持已有 Server 数据库升级。 +1.3.0 升级必须通过 SQLite、seekDB 和 OceanBase 的完整 Server 验收。当前迁移路径尚未验证,请勿升级已有数据库。 已有部署应保持当前可用版本,等待来源版本及后端具备已验收的升级路径。试用新建部署时,请使用独立程序环境、 diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 77d31c67eb..7718d463eb 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -1,10 +1,10 @@ { "format": 2, "target_version": "1.4.0", - "mode": "fresh-install-only", + "mode": "upgrade-required", "schema_review": { "persistence_sha256": "ca3b475f2d0c883e19291a8d8b1dadbe50f93ebd93aad550a9112a3497737927", - "rationale": "The full Server upgrade framework is incomplete (#2154). Existing databases are unsupported; no production upgrade combination is advertised until installed-release acceptance passes." + "rationale": "Full Server upgrades from 1.3.0 are mandatory on SQLite, embedded seekDB and OceanBase. Acceptance must fail until the migration integration tracked in #2154 is complete." }, "cases": [], "automatic_sources": { @@ -14,17 +14,17 @@ { "backend": "sqlite", "configuration": "full-server-no-vector", - "suite": null + "suite": "tests/release_upgrades/test_full_server.py" }, { "backend": "seekdb", "configuration": "full-server-no-vector", - "suite": null + "suite": "tests/release_upgrades/test_full_server.py" }, { "backend": "oceanbase", "configuration": "full-server-no-vector", - "suite": null + "suite": "tests/release_upgrades/test_full_server.py" } ] } diff --git a/scripts/upgrade_database_probe.py b/scripts/upgrade_database_probe.py index 0188b77823..53618388b2 100644 --- a/scripts/upgrade_database_probe.py +++ b/scripts/upgrade_database_probe.py @@ -39,8 +39,18 @@ async def inspect(backend: str, database: Path, *, add_revision: bool) -> dict[s with sqlite3.connect(database) as connection: if add_revision: connection.execute(REVISION_DDL) - rows = connection.execute("SELECT name, sql FROM sqlite_schema WHERE type='table' ORDER BY name").fetchall() - return {"engine_version": sqlite3.sqlite_version, "driver_version": version("aiosqlite"), "tables": dict(rows)} + objects = { + plural: dict( + connection.execute("SELECT name, sql FROM sqlite_schema WHERE type=? ORDER BY name", (kind,)) + ) + for kind, plural in ( + ("table", "tables"), + ("index", "indexes"), + ("view", "views"), + ("trigger", "triggers"), + ) + } + return {"engine_version": sqlite3.sqlite_version, "driver_version": version("aiosqlite"), **objects} # Use the driver's public API, not a current-checkout Profile that could run # startup guards or mutate an old schema before the target CLI is tested. @@ -79,11 +89,17 @@ async def inspect(backend: str, database: Path, *, add_revision: bool) -> dict[s for name in names: cursor.execute("SHOW CREATE TABLE `" + name.replace("`", "``") + "`") tables[name] = cursor.fetchone()[1] + cursor.execute("SHOW FULL TABLES WHERE Table_type = 'VIEW'") + views = {} + for name in sorted(row[0] for row in cursor.fetchall()): + cursor.execute("SHOW CREATE VIEW `" + name.replace("`", "``") + "`") + views[name] = cursor.fetchone()[1] return { "engine_version": engine, "driver_version": version("pymysql"), **details, "tables": tables, + "views": views, } finally: if instance is not None: diff --git a/scripts/upgrade_release_gate.py b/scripts/upgrade_release_gate.py index cf0a3cbc4d..0ba593cd7f 100644 --- a/scripts/upgrade_release_gate.py +++ b/scripts/upgrade_release_gate.py @@ -53,8 +53,8 @@ }) VERSION = r"\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?" NOTICE = { - "en": "This release supports fresh installations only; upgrades of existing Server databases are not supported.", - "zh": "此版本仅支持新建部署,尚不支持已有 Server 数据库升级。", # noqa: RUF001 -- Chinese prose + "en": "Upgrades from 1.3.0 require passing full Server acceptance on SQLite, seekDB and OceanBase. This migration path is not yet verified; do not upgrade existing databases.", + "zh": "1.3.0 升级必须通过 SQLite、seekDB 和 OceanBase 的完整 Server 验收。当前迁移路径尚未验证,请勿升级已有数据库。", # noqa: RUF001 -- Chinese prose } @@ -100,7 +100,7 @@ def load_policy(root: Path) -> dict[str, Any]: require( match is not None and match[1] == policy["target_version"], "Upgrade policy target differs from API release" ) - require(policy["mode"] in {"fresh-install-only", "supported-upgrades"}, "Unknown upgrade policy mode") + require(policy["mode"] in {"upgrade-required", "supported-upgrades"}, "Unknown upgrade policy mode") review = policy["schema_review"] require(set(review) == {"persistence_sha256", "rationale"}, "Invalid schema review fields") require( @@ -124,8 +124,12 @@ def load_policy(root: Path) -> dict[str, Any]: require(isinstance(profiles, list) and profiles, "Automatic sources require backend profiles") for profile in profiles: require(set(profile) == {"backend", "configuration", "suite"}, "Invalid automatic backend profile") - supported = policy["mode"] == "supported-upgrades" - require(bool(cases or profiles) if supported else not cases, "Upgrade mode and support matrix disagree") + require(bool(cases or profiles), "Upgrade acceptance requires a nonempty matrix") + required_backends = {"sqlite", "seekdb", "oceanbase"} + covered = {case["backend"] for case in cases if case.get("source_version") == "1.3.0"} | ( + {profile["backend"] for profile in profiles} if automatic and automatic["source_version"] == "1.3.0" else set() + ) + require(covered >= required_backends, "The 1.3.0 upgrade baseline requires SQLite, seekDB and OceanBase") identities: set[tuple[str, str, str]] = set() for case in cases: require(set(case) == {"source_version", "backend", "configuration", "suite"}, "Invalid upgrade case fields") @@ -139,9 +143,10 @@ def load_policy(root: Path) -> dict[str, Any]: identity = (case.get("source_version", "automatic"), case["backend"], case["configuration"]) require(identity not in identities, "Duplicate upgrade combination") identities.add(identity) - if case["suite"] is None: - require(not supported, "Supported upgrades require a real acceptance suite for every backend profile") - continue + require( + isinstance(case["suite"], str) and case["suite"], + "A real acceptance suite is required for every backend profile", + ) suite = (root / case["suite"]).resolve() require( suite.is_relative_to((root / "tests/release_upgrades").resolve()), @@ -154,7 +159,7 @@ def load_policy(root: Path) -> dict[str, Any]: def support_text(policy: dict[str, Any], language: str) -> str: - if policy["mode"] == "fresh-install-only": + if policy["mode"] == "upgrade-required": return NOTICE[language] title = ( "Supported database upgrades (other combinations are unsupported):" @@ -256,7 +261,7 @@ def run_case(root: Path, wheel: Path, case: dict[str, Any], output: Path) -> dic for key, value in os.environ.items() if not key.startswith(("POWERCONTEXT_", "PYTEST_")) and key not in {"PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"} } - with tempfile.TemporaryDirectory(prefix="pc-upgrade-") as temporary: + with tempfile.TemporaryDirectory(prefix="pc-up-", dir="/tmp" if os.name != "nt" else None) as temporary: # noqa: S108 -- private mkdtemp directory; short embedded socket path directory = Path(temporary) extras = "cli,server,seekdb" if case["backend"] == "seekdb" else "cli,server" interpreters = {} @@ -284,7 +289,9 @@ def run_case(root: Path, wheel: Path, case: dict[str, Any], output: Path) -> dic metadata = {role: installed(python, environment, directory) for role, python in interpreters.items()} require(metadata["source"]["powercontext"] == case["source_version"], "Source release mismatch") require(metadata["target"]["powercontext"] == wheel_version(wheel), "Target wheel version mismatch") + (output / "installed.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8") environment.update({ + "PC_UPGRADE_REPORT_DIR": str(output), "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", "PC_UPGRADE_SOURCE_PYTHON": str(interpreters["source"]), "PC_UPGRADE_SOURCE_VERSION": case["source_version"], @@ -344,8 +351,22 @@ def gate(root: Path, wheel: Path, output: Path, *, release: bool = False, releas try: matrix = resolve_matrix(policy, actual_version) evidence["resolved_matrix"] = matrix + require(matrix["required_cases"], "No required upgrade acceptance cases") + failures = [] for index, case in enumerate(matrix["required_cases"]): - evidence["cases"].append(run_case(root, wheel, case, output / f"case-{index}")) + try: + evidence["cases"].append(run_case(root, wheel, case, output / f"case-{index}")) + except (GateError, OSError, ValueError, subprocess.SubprocessError, ElementTree.ParseError) as error: + # A broken backend must not prevent the other required backends + # from running and producing their own failure evidence. + failures.append(case["backend"]) + evidence["cases"].append({"case": case, "status": "failed", "error": str(error)}) + require(not failures, f"Required upgrade acceptance failed: {', '.join(failures)}") + if release: + require( + policy["mode"] == "supported-upgrades", + "Upgrade acceptance passed; update the reviewed support declaration before publication", + ) evidence["status"] = "passed" finally: (output / "result.json").write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") diff --git a/scripts/upgrade_release_matrix.py b/scripts/upgrade_release_matrix.py index dbb56b33ce..9ed8cbb731 100644 --- a/scripts/upgrade_release_matrix.py +++ b/scripts/upgrade_release_matrix.py @@ -63,7 +63,7 @@ def resolve_matrix(policy: dict[str, Any], target: str) -> dict[str, Any]: automatic = policy["automatic_sources"] explicit = policy["cases"] if automatic is None and not explicit: - return {"candidate_cases": [], "required_cases": []} + raise ValueError("Upgrade acceptance requires a nonempty matrix") # noqa: TRY003 catalog = fetch_catalog() if not isinstance(catalog.get("releases"), dict) or not catalog["releases"]: raise ValueError("Empty or malformed PyPI release catalog") # noqa: TRY003 @@ -84,7 +84,5 @@ def resolve_matrix(policy: dict[str, Any], target: str) -> dict[str, Any]: "catalog_url": CATALOG_URL, "catalog_sha256": hashlib.sha256(json.dumps(catalog, sort_keys=True).encode()).hexdigest(), "candidate_cases": cases, - # Fresh-install-only records discovery, never reports unexecuted upgrades - # as accepted. Switching mode requires real suites for every profile. - "required_cases": cases if policy["mode"] == "supported-upgrades" else [], + "required_cases": cases, } diff --git a/tests/conftest.py b/tests/conftest.py index 91bda3b638..d8fd628be4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -161,6 +161,10 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item def pytest_ignore_collect(collection_path: Path, config: pytest.Config) -> bool | None: + # Installed-release suites run only through the mandatory artifact gate. + # The gate uses --confcutdir to exclude this repository conftest entirely. + if collection_path.resolve() == (Path(__file__).parent / "release_upgrades").resolve(): + return True # Alembic executes env.py as migration configuration; doctest collection # must not import it as a test module. if collection_path.resolve() == _DATABASE_MIGRATION_ENV.resolve(): diff --git a/tests/release_upgrades/README.md b/tests/release_upgrades/README.md index 4e7eddff93..a30d9d1b9b 100644 --- a/tests/release_upgrades/README.md +++ b/tests/release_upgrades/README.md @@ -1,8 +1,9 @@ # Installed-release upgrade acceptance -`release/upgrade-support.json` is the explicit upgrade support policy. Currently it declares -`fresh-install-only`: **no production database upgrade combination is supported**. Discovered candidates in this mode -are not accepted upgrades. Full migration integration is tracked in #2154. +`release/upgrade-support.json` requires **positive** 1.3.0 upgrades on SQLite, embedded seekDB, and OceanBase. +The current `upgrade-required` mode keeps the upgrade support declaration unverified while requiring every case +to run and pass. Missing full migration integration (#2154) fails the gate. `supported-upgrades` runs the same +matrix and enables publication after the reviewed support declaration is updated. `fresh-install-only` is rejected. ## Fixed source and current target @@ -18,11 +19,12 @@ source wheels, invalid artifact metadata, catalog outages, and a target at or be Evidence records the catalog digest, exact source artifact, target artifact digest, and expanded matrix. The resolver requires universal `py3-none-any` wheels; a distribution format change must extend it explicitly. -Currently the three profiles have `suite: null` and the mode remains `fresh-install-only`. Resolving candidates -executes and accepts **zero** positive upgrades. To activate supported upgrades, implement the complete suite -contract below for each backend, replace the null paths, set `mode` to `supported-upgrades`, and regenerate the -installation notices. A missing suite fails supported mode. Tests must fail when migration support is missing; -the runner never changes expected success into a rejection automatically. +All three profiles use `test_full_server.py`. It starts the installed 1.3.0 Server, writes data through HTTP, +stops it, seeds a pending processing intent using the installed SDK, and runs the target wheel's public +`server db-migrate status/plan/apply/verify` commands. Verification must report `state=ready` and `server_ready=true`. +The target Server must then start, preserve data and permissions, accept new writes, and support retrieval. +Partial migration success, missing framework/baseline support, and startup rejection all fail. These disposable +fixtures explicitly accept no backup; this is not a production upgrade procedure. `cases` remains available for additional exact source versions/configurations. Each case needs `source_version`, `backend`, `configuration`, and `suite`; overlapping cases fail. Optional features that create persistent objects @@ -45,6 +47,7 @@ The suite receives: - `PC_UPGRADE_BACKEND`, `PC_UPGRADE_CONFIGURATION`: the declared case. - `PC_UPGRADE_WORK`: disposable workspace for both databases, controlled inference fixtures, and environment files. - `PC_UPGRADE_EVIDENCE`: destination for backend evidence JSON. +- `PC_UPGRADE_REPORT_DIR`: persistent per-case command logs, fixture snapshots and schema inventories. - `PC_UPGRADE_OCEANBASE_URL`, `PC_UPGRADE_OCEANBASE_FRESH_URL`: dedicated disposable MySQL-tenant databases for the upgraded and fresh-install comparisons. CI supplies both; local positive OceanBase suites must supply their own isolated databases. Each suite must isolate/reset its own fixtures when multiple configurations run. @@ -58,9 +61,9 @@ Provide these behavioral tests (additional tests are allowed): | `test_target_startup` | Start the target Server against the migrated database and verify readiness. | | `test_read_write_and_retrieval` | Read the old records, write new ones, and retrieve both through public interfaces. | | `test_authorization` | Verify permitted and denied access and preservation of relevant grants. | -| `test_candidates_and_processing` | Verify retained Candidates and task consumption, including idempotency. | +| `test_candidates_and_processing` | Verify retained Candidates and CAS approval; load and acknowledge the old pending intent using the target installed SDK, including idempotency. This does not execute a model-driven Worker. | | `test_preserved_history_and_cursors` | Verify exact history/references and unchanged or declared-transformed cursor and receipt semantics. | -| `test_fresh_upgrade_equivalence` | Initialize a fresh target database; compare schema/backend objects and behavior with the upgraded database, explicitly allowing only registered retained/archive objects. | +| `test_fresh_upgrade_equivalence` | Initialize a fresh target database; compare schema/backend objects and behavior with the upgraded database. The current suite has no retained/archive object exclusions. | Use a session fixture for the shared lifecycle; do not depend on pytest's test execution order. All collected tests must execute and pass. Empty collection, missing phases, skips (including missing backends), xfail, failure, command @@ -75,24 +78,14 @@ records installed Python/package versions, target wheel SHA-256, policy SHA-256, Evidence is created during the gate run in a new directory. There is no option to import old successful reports. Test names and metadata do not themselves prove correctness: reviewers must inspect the assertions and confirm -they follow the public operations and compare actual persisted data. This contract is intended to host the real -upgrade acceptance added with #2154, without claiming that it already exists. - -For example, after implementing and reviewing actual suites, automatic selection would have this shape: - -```json -{ - "strategy": "fixed-version", - "source_version": "1.3.0", - "profiles": [ - {"backend": "sqlite", "configuration": "full-server-no-vector", "suite": "tests/release_upgrades/test_sqlite.py"}, - {"backend": "seekdb", "configuration": "full-server-no-vector", "suite": "tests/release_upgrades/test_seekdb.py"}, - {"backend": "oceanbase", "configuration": "full-server-no-vector", "suite": "tests/release_upgrades/test_oceanbase.py"} - ] -} -``` - -These suite paths illustrate the contract; they do not exist yet. Regenerate installation support regions with +they follow actual installed operations and compare persisted data. The profile exercises explicit writes and FTS, +not vector storage or model-generated content. The processing fixture tests persisted intent compatibility and +acknowledgment, not Worker execution. Add separately reviewed profiles before claiming those optional paths. +SQLite inventories include indexes, views and triggers; MySQL-family table DDL includes indexes and constraints, +and views are collected separately. Comparison normalizes whitespace, identifier quoting and auto-increment +counters only; structural differences fail rather than being silently excluded. + +Regenerate installation support regions with `uv run python scripts/upgrade_release_gate.py --write-docs` and run `make upgrade-policy-check`. When persistence inputs change, calculate `managed_digest(ROOT)` from `upgrade_release_gate`, review the compatibility impact, and update both the digest and rationale in `schema_review`. The inputs include persistence, optional code storage, @@ -109,23 +102,9 @@ Repository administrators must require **Upgrade release gate** in branch protec that setting. Because the release workflow starts on `release.published`, it can block package/asset publication, not the earlier manual creation of a GitHub Release; run the prepublication command before publishing that page. -## Actual historical rejection regressions - -The PR/master and publication jobs run `ci_legacy_upgrade_rejection.py` for **SQLite, embedded seekDB, and OceanBase** -against a database created and populated by installed PowerContext 1.3.0. It uses separate source/target environments, one disposable -database per backend, sequential process ownership, and public HTTP writes/readback before testing target CLI behavior. -The installed drivers inventory the actual tables and record each environment's engine, driver, and binding versions. -OceanBase uses the container's dedicated `pc_upgrade_test` database and records its MySQL tenant mode and direct -connection. Its public migration status command supplies `--evidence-dir` and `--lock-coordination single-host`, -as required by OceanBase maintenance. The negative runner only accepts the local test database and verifies it is empty before starting the -old Server; it refuses to overwrite existing data. No external service or credentials are needed. A missing embedded -binding or unavailable OceanBase instance fails the job; neither backend is skipped. - -The regressions require historical startup rejection, full-baseline migration rejection, and rejection after adding -only the prototype revision table to the disposable fixture. The last operation is deliberately **not a migration**. -SeekDB and OceanBase permit either the known CHECK-reflection rejection or unknown-baseline rejection, so fixing #2160 does not -require retaining the parser defect. These tests do not certify that an upgrade succeeds. - -CI invokes them with `--respect-policy` after the main gate. Once supported-path acceptance is activated, these -historical rejection assertions are recorded as `not-applicable` instead of failing because migration was fixed. -The preceding positive gate must still execute all required version/backend cases and accept no skips. +## Historical rejection regressions + +`scripts/ci_legacy_upgrade_rejection.py` remains a diagnostic tool for historical rejection behavior. PR and release +workflows do not invoke it as upgrade acceptance. An expected rejection or its `--respect-policy` result can never +replace a successful positive upgrade. The gate attempts every required backend even after an earlier case fails, +then fails overall and archives each case's logs and result. Empty matrices and missing suites are errors. diff --git a/tests/release_upgrades/processing_fixture.py b/tests/release_upgrades/processing_fixture.py new file mode 100644 index 0000000000..87afd80ffd --- /dev/null +++ b/tests/release_upgrades/processing_fixture.py @@ -0,0 +1,60 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Exercise stored invocation format compatibility using each installed SDK. + +Run with the selected installed interpreter and -I, with Server processes stopped. +No schema creation or direct SQL writes: the installed repository owns its rows. +""" + +import asyncio +import json +import sys +from pathlib import Path + +from powercontext.builtin.persistence.oceanbase import OceanBaseProfile +from powercontext.builtin.persistence.processing_intents import ArtifactProcessingIntentRepository +from powercontext.builtin.persistence.seekdb import SeekDBProfile +from powercontext.builtin.persistence.sqlite import SQLiteProfile +from powercontext.server.settings import ServerSettings + + +async def main(): + operation, scope = sys.argv[1:3] + config = ServerSettings().database + if config.kind == "sqlite": + storage_context = SQLiteProfile.open(config, tables=()) + elif config.kind == "seekdb": + storage_context = SeekDBProfile.open(config, tables=()) + else: + storage_context = OceanBaseProfile.open(config, tables=()) + repository = ArtifactProcessingIntentRepository() + binding = "topic-memory-source-window" + async with storage_context as storage, storage.database.transaction() as connection: + if operation == "seed": + row = await repository.request(connection, scope, binding) + result = row.model_dump(mode="json") + else: + row = await repository.load(connection, scope, binding) + assert row is not None and row.requested_generation > row.handled_generation + before = row.model_dump(mode="json") + after = await repository.acknowledge(connection, scope, binding, row.requested_generation) + repeated = await repository.acknowledge(connection, scope, binding, row.requested_generation) + assert after == repeated, "Acknowledgment must be idempotent" + result = {"before": before, "after": after.model_dump(mode="json")} + return result + + +if __name__ == "__main__": + Path(sys.argv[3]).write_text(json.dumps(asyncio.run(main()), indent=2) + "\n", encoding="utf-8") diff --git a/tests/release_upgrades/test_full_server.py b/tests/release_upgrades/test_full_server.py new file mode 100644 index 0000000000..7212eec944 --- /dev/null +++ b/tests/release_upgrades/test_full_server.py @@ -0,0 +1,465 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Installed 1.3.0 -> built wheel acceptance; every required phase must succeed. + +No checkout runtime imports, migration fallbacks, skips or expected failures. +Fixtures establish dependencies rather than relying on test execution order. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import signal +import socket +import subprocess +import time +from contextlib import contextmanager +from pathlib import Path +from urllib.parse import urlsplit + +import httpx +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TOKEN = "isolated-upgrade-acceptance-token" # noqa: S105 -- disposable fixture, never a deployment credential + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest() + + +def console(python): + return str(python.with_name("powercontext.exe" if os.name == "nt" else "powercontext")) + + +def command(arguments, environment, work, log): + with log.open("w", encoding="utf-8") as stream: + result = subprocess.run( + arguments, cwd=work, env=environment, stdout=stream, stderr=subprocess.STDOUT, timeout=1800, check=False + ) + assert result.returncode == 0, f"Command failed ({result.returncode}); see {log}" + return log.read_text(encoding="utf-8") + + +def post(client, path, body): + response = client.post(path, json=body) + assert response.is_success, f"{path}: HTTP {response.status_code}: {response.text}" + return response.json() + + +@contextmanager +def server(python, environment, work, log): + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + with log.open("w", encoding="utf-8") as stream: + process = subprocess.Popen( + [console(python), "server", "run", "--no-env-file", "--host", "127.0.0.1", "--port", str(port)], + cwd=work, + env=environment, + stdout=stream, + stderr=subprocess.STDOUT, + start_new_session=os.name != "nt", + text=True, + ) + try: + with httpx.Client( + base_url=f"http://127.0.0.1:{port}", + headers={"Authorization": f"Bearer {TOKEN}"}, + trust_env=False, + timeout=30, + ) as client: + deadline = time.monotonic() + 180 + while True: + assert process.poll() is None, f"Server exited before readiness; see {log}" + try: + response = client.get("/health/ready") + if response.status_code == 200 and response.json().get("status") in {"ready", "degraded"}: + break + except httpx.TransportError: + pass + assert time.monotonic() < deadline, f"Server readiness timed out; see {log}" + time.sleep(0.2) + yield client + finally: + if process.poll() is None: + if os.name == "nt": + process.terminate() + else: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=30) + except subprocess.TimeoutExpired: + if os.name == "nt": + process.kill() + else: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=10) + + +def environment_for(case, *, fresh=False): + environment = { + key: value + for key, value in os.environ.items() + if not key.startswith(("POWERCONTEXT_", "PYTEST_", "OPENAI_", "ANTHROPIC_", "OTEL_")) + and key not in {"PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"} + } + environment.update({ + "POWERCONTEXT_HOME": str(case["work"] / ("fresh-home" if fresh else "home")), + "POWERCONTEXT_SERVER_AUTH_TOKEN": TOKEN, + "POWERCONTEXT_SERVER_ACCESS_MODE": "enforced", + "POWERCONTEXT_SERVER_CURSOR_SIGNING_SECRET": "isolated-upgrade-cursor-signing-key-0001", + "POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_ROLE": "api", + "POWERCONTEXT_SERVER_MCP_ENABLED": "false", + "NO_PROXY": "localhost,127.0.0.1", + "no_proxy": "localhost,127.0.0.1", + }) + database = case["work"] / ("fresh" if fresh else "database") + backend = case["backend"] + environment["POWERCONTEXT_SERVER_DATABASE_KIND"] = backend + if backend == "sqlite": + environment["POWERCONTEXT_SERVER_DATABASE_URL"] = f"sqlite+aiosqlite:///{database}" + elif backend == "seekdb": + environment["POWERCONTEXT_SERVER_DATABASE_PATH"] = str(database) + else: + name = "PC_UPGRADE_OCEANBASE_FRESH_URL" if fresh else "PC_UPGRADE_OCEANBASE_URL" + url = os.environ[name] + parsed = urlsplit(url) + expected = "/pc_upgrade_fresh" if fresh else "/pc_upgrade_test" + assert parsed.scheme == "mysql+aoceanbase" and parsed.hostname in {"127.0.0.1", "localhost"} + assert parsed.path == expected, "Only dedicated disposable OceanBase databases may be used" + environment["POWERCONTEXT_SERVER_DATABASE_URL"] = url + environment["PC_UPGRADE_OCEANBASE_URL"] = url + return environment + + +def snapshot(case, role, name, *, fresh=False): + path = case["report"] / f"{name}-schema.json" + command( + [ + str(case[role]), + "-I", + str(ROOT / "scripts/upgrade_database_probe.py"), + "--backend", + case["backend"], + "--database", + str(case["work"] / ("fresh" if fresh else "database")), + "--output", + str(path), + ], + environment_for(case, fresh=fresh), + case["work"], + case["report"] / f"{name}-probe.log", + ) + return json.loads(path.read_text()) + + +def task_state(case, role, operation, scope): + path = case["report"] / f"task-{operation}.json" + command( + [ + str(case[role]), + "-I", + str(ROOT / "tests/release_upgrades/processing_fixture.py"), + operation, + scope, + str(path), + ], + environment_for(case), + case["work"], + case["report"] / f"task-{operation}.log", + ) + return json.loads(path.read_text()) + + +def seed(client, *, candidate_prefix): + scopes = [ + post( + client, + "/v1/scopes", + { + "title": f"Upgrade fixture {i}", + "summary": "Installed release acceptance", + "idempotency_key": f"upgrade-scope-{i}", + }, + ) + for i in range(3) + ] + scope = scopes[0]["scope_id"] + memory = [ + post(client, "/v1/memory/remember", {"scope_id": scope, "kind": "fact", "text": text})["records"][0] + for text in ("Upgrade fixture preserves historical storage.", "Upgrade fixture retains exact references.") + ] + captured = post( + client, + "/v1/sources/content", + { + "scope_id": scope, + "source_id": "upgrade-evidence", + "content": "Historical source evidence for upgrade candidate review.", + }, + )["source"] + proposal = { + "situation": "A release upgrades storage", + "action": "Run migration acceptance", + "outcome": "History survives", + "lesson": "Validate real old databases", + } + body = {"scope_id": scope, "proposal": proposal, "source_refs": [captured], "artifact_refs": []} + candidate = post(client, "/v1/experience/propose", body) + first = candidate + candidate = post( + client, + f"{candidate_prefix}/revise", + { + **body, + "candidate_id": candidate["candidate_id"], + "expected_version": candidate["version"], + "proposal": {**proposal, "lesson": "Validate every required backend"}, + }, + ) + published = post(client, "/v1/experience/propose", body) + published = post( + client, + f"{candidate_prefix}/approve", + {"scope_id": scope, "candidate_id": published["candidate_id"], "expected_version": published["version"]}, + ) + artifact = post(client, "/v1/experience/get", {"scope_id": scope, "artifact": published["result_artifact"]}) + principal = client.get("/v1/access/me").json()["principal"] + grant = { + "subject": principal, + "resource": {"type": "scope", "scope_id": scope}, + "role": "scope.viewer", + "idempotency_key": "upgrade-viewer", + } + binding = post(client, "/v1/access/bindings/create", grant) + first_page = client.get("/v1/scopes", params={"limit": 1}).json() + cursor = first_page["next_cursor"] + assert cursor + continuation = client.get("/v1/scopes", params={"limit": 1, "cursor": cursor}).json() + assert continuation["items"] + readback = post(client, "/v1/memory/entries/list", {"scope_id": scope})["entries"] + assert {digest(item) for item in readback} >= {digest(item) for item in memory} + return { + "scope": scope, + "memory": memory, + "candidate": candidate, + "first_candidate": first, + "published": published, + "artifact": artifact, + "grant": grant, + "binding": binding, + "cursor": cursor, + "continuation": continuation, + } + + +@pytest.fixture(scope="session") +def case(): + # Missing gate inputs are errors, never skips or an implicit local database. + assert os.environ["PC_UPGRADE_SOURCE_VERSION"] == "1.3.0", "Add a reviewed adapter for another source release" + return { + "source": Path(os.environ["PC_UPGRADE_SOURCE_PYTHON"]), + "target": Path(os.environ["PC_UPGRADE_TARGET_PYTHON"]), + "backend": os.environ["PC_UPGRADE_BACKEND"], + "work": Path(os.environ["PC_UPGRADE_WORK"]), + "report": Path(os.environ["PC_UPGRADE_REPORT_DIR"]), + } + + +@pytest.fixture(scope="session") +def historical(case): + if case["backend"] == "oceanbase": + assert not snapshot(case, "source", "empty")["tables"], "Refusing to write into a nonempty test database" + assert not snapshot(case, "target", "fresh-empty", fresh=True)["tables"] + with server(case["source"], environment_for(case), case["work"], case["report"] / "source-server.log") as client: + value = seed(client, candidate_prefix="/v1/artifact-candidates") + value["task"] = task_state(case, "source", "seed", value["scope"]) + value["schema"] = snapshot(case, "source", "source") + assert {"pc_artifact_candidate_heads", "pc_artifact_candidate_versions", "pc_scopes", "pc_artifacts"} <= set( + value["schema"]["tables"] + ) + (case["report"] / "source-fixture.json").write_text(json.dumps(value, indent=2) + "\n") + return value + + +@pytest.fixture(scope="session") +def migrated(case, historical): + environment = environment_for(case) + env_file = case["work"] / "deployment.env" + env_file.write_text( + "".join(f"{key}={value}\n" for key, value in environment.items() if key.startswith("POWERCONTEXT_")) + ) + common = ["--env-file", str(env_file)] + if case["backend"] == "oceanbase": + common += ["--evidence-dir", str(case["report"] / "maintenance"), "--lock-coordination", "single-host"] + + def migration(operation, *options): + text = command( + [console(case["target"]), "server", "db-migrate", operation, *common, *options], + environment, + case["work"], + case["report"] / f"migration-{operation}.log", + ) + result = json.loads(text) + assert not result.get("error"), result + return result + + migration("status") + plan = migration("plan", "--backup", "skip") + assert plan.get("plan_id"), "The public plan must identify exactly what will be applied" + # These are disposable fixtures; all Server processes are stopped. This + # explicit no-backup choice is never a recommendation for production. + migration( + "apply", + "--backup", + "skip", + "--plan-id", + plan["plan_id"], + "--yes", + "--maintenance-confirmed", + "--accept-no-backup", + ) + verified = migration("verify") + assert verified.get("state") == "ready" and verified.get("server_ready") is True, ( + "Full Server readiness is required; registered-bundle verification is insufficient", + verified, + ) + return {"verified": verified, "schema": snapshot(case, "target", "migrated"), "historical": historical} + + +@pytest.fixture +def target(case, migrated): + with server(case["target"], environment_for(case), case["work"], case["report"] / "target-server.log") as client: + yield client + + +def test_source_release_database(historical): + assert historical["memory"] and historical["candidate"]["version"] == 2 + assert historical["task"]["requested_generation"] > historical["task"]["handled_generation"] + + +def test_public_migration_commands(migrated): + assert migrated["verified"]["server_ready"] is True + + +def test_target_startup(target): + response = target.get("/health/ready") + assert response.status_code == 200 + assert response.json()["status"] in {"ready", "degraded"} # No inference provider in this explicit-write profile. + + +def test_read_write_and_retrieval(target, historical): + scope = historical["scope"] + records = post(target, "/v1/memory/entries/list", {"scope_id": scope})["entries"] + assert {digest(item) for item in records} >= {digest(item) for item in historical["memory"]} + text = "Upgrade fixture accepts new writes after migration." + written = post(target, "/v1/memory/remember", {"scope_id": scope, "kind": "fact", "text": text})["records"] + assert written and written[0]["text"] == text + found = post(target, "/v1/memory/search", {"scope_id": scope, "query": "Upgrade fixture", "mode": "fts"})["hits"] + texts = {item["memory"]["text"] for item in found} + assert texts >= {text, *(item["text"] for item in historical["memory"])} + + +def test_authorization(target, historical): + assert target.get("/v1/scopes", headers={"Authorization": "Bearer invalid-fixture-token"}).status_code == 401 + # The stable idempotency key must resolve to the SAME persisted grant. + assert post(target, "/v1/access/bindings/create", historical["grant"]) == historical["binding"] + response = target.get(f"/v1/scopes/{historical['scope']}") + assert response.status_code == 200 + + +def test_candidates_and_processing(case, migrated): + historical = migrated["historical"] + # Native typed SDK loading/acknowledgment verifies stored invocation formats + # and generation idempotency; model-driven Worker quality is a separate suite. + state = task_state(case, "target", "verify", historical["scope"]) + assert {key: state["before"][key] for key in historical["task"]} == historical["task"] + assert state["after"]["handled_generation"] == historical["task"]["requested_generation"] + with server(case["target"], environment_for(case), case["work"], case["report"] / "candidate-server.log") as client: + candidate = historical["candidate"] + request = {"scope_id": historical["scope"], "candidate_id": candidate["candidate_id"]} + current = post(client, "/v1/candidates/get", request) + for key in ("candidate_id", "version", "status", "proposal", "source_refs", "artifact_refs"): + assert current[key] == candidate[key] + approved = post(client, "/v1/candidates/approve", {**request, "expected_version": candidate["version"]}) + assert approved["status"] == "approved" and approved["result_artifact"] + repeated = client.post("/v1/candidates/approve", json={**request, "expected_version": candidate["version"]}) + assert repeated.status_code == 409, "A stale approval must not publish a duplicate Artifact" + + +def test_preserved_history_and_cursors(target, historical): + expected = historical["continuation"] + page = target.get("/v1/scopes", params={"limit": 1, "cursor": historical["cursor"]}) + assert page.status_code == 200 + assert page.json() == expected + artifact = post( + target, + "/v1/experience/get", + {"scope_id": historical["scope"], "artifact": historical["published"]["result_artifact"]}, + ) + assert artifact == historical["artifact"] + history = post( + target, + "/v1/candidates/history", + {"scope_id": historical["scope"], "candidate_id": historical["candidate"]["candidate_id"]}, + )["versions"] + for version in (historical["first_candidate"], historical["candidate"]): + stored = next(item for item in history if item["version"] == version["version"]) + for key in ("version", "proposal", "source_refs", "artifact_refs"): + assert stored[key] == version[key] + + +def canonical_schema(snapshot): + def normalize(sql): + sql = re.sub(r"\bAUTO_INCREMENT=\d+", "", sql) + sql = re.sub(r'["`]([A-Za-z_][A-Za-z_0-9]*)["`]', r"\1", sql) + return " ".join(sql.split()) + + return { + kind: {name: normalize(sql or "") for name, sql in snapshot.get(kind, {}).items()} + for kind in ("tables", "indexes", "views", "triggers") + } + + +def test_fresh_upgrade_equivalence(case, migrated): + with server( + case["target"], environment_for(case, fresh=True), case["work"], case["report"] / "fresh-server.log" + ) as client: + fresh = seed(client, candidate_prefix="/v1/candidates") + hits = post( + client, "/v1/memory/search", {"scope_id": fresh["scope"], "query": "Upgrade fixture", "mode": "fts"} + )["hits"] + assert {item["memory"]["text"] for item in hits} >= {item["text"] for item in fresh["memory"]} + inventory = snapshot(case, "target", "fresh", fresh=True) + actual = canonical_schema(migrated["schema"]) + expected = canonical_schema(inventory) + assert actual == expected, ( + "Upgraded and fresh database objects differ; no blanket table/constraint exclusions allowed" + ) + evidence = { + "backend": case["backend"], + "engine_version": inventory["engine_version"], + "driver_version": inventory["driver_version"], + "source_schema": digest(migrated["historical"]["schema"]), + "target_schema": digest(actual), + "fresh_schema": digest(expected), + } + if case["backend"] == "oceanbase": + evidence.update(tenant_mode=inventory["tenant_mode"], proxy_version=inventory["proxy_version"]) + Path(os.environ["PC_UPGRADE_EVIDENCE"]).write_text(json.dumps(evidence, indent=2) + "\n") diff --git a/tests/test_release_version.py b/tests/test_release_version.py index 74a5bca627..b7542c699e 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -102,8 +102,8 @@ def test_release_updates_installation_references_and_preserves_history(release_r assert f"To install {version} in a separate environment" in english assert f"Python 包版本为 `{version}`" in chinese assert f"安装 {version} 到独立环境" in chinese - assert "upgrades of existing Server databases are not supported" in english - assert "尚不支持已有 Server 数据库升级" in chinese + assert "Upgrades from 1.3.0 require passing full Server acceptance" in english + assert "1.3.0 升级必须通过 SQLite、seekDB 和 OceanBase 的完整 Server 验收" in chinese dsh = (release_repo / "integrations/dsh/plugins/powercontext/README.md").read_text(encoding="utf-8") assert "Release 1.1.0 includes direct-operation Scope failure handling" in dsh for relative, original in preserved.items(): diff --git a/tests/test_upgrade_release_gate.py b/tests/test_upgrade_release_gate.py index 28525694aa..66a77dc776 100644 --- a/tests/test_upgrade_release_gate.py +++ b/tests/test_upgrade_release_gate.py @@ -41,11 +41,16 @@ def release(tmp_path, monkeypatch): policy = { "format": 2, "target_version": "2.0.0", - "mode": "fresh-install-only", + "mode": "upgrade-required", "schema_review": {"persistence_sha256": gate.managed_digest(tmp_path), "rationale": "No upgrades supported"}, "cases": [], "automatic_sources": None, } + suite = tmp_path / "tests/release_upgrades/test_example.py" + suite.parent.mkdir(parents=True) + suite.write_text("# Gate orchestration fixture; not real upgrade acceptance.\n") + automatic_policy(policy) + monkeypatch.setattr(gate, "run_case", lambda _root, _wheel, case, _output: {"case": case, "status": "passed"}) (tmp_path / gate.MANIFEST).write_text(json.dumps(policy)) monkeypatch.setattr( matrix, @@ -74,11 +79,14 @@ def release(tmp_path, monkeypatch): return tmp_path, wheel, policy -def test_fresh_install_policy_does_not_claim_upgrade_acceptance(release): +def test_required_mode_executes_three_cases_but_cannot_publish_as_supported(release): root, wheel, policy = release - gate.gate(root, wheel, root / "evidence", release=True, release_notes=gate.support_text(policy, "en")) + with pytest.raises(gate.GateError, match="support declaration"): + gate.gate(root, wheel, root / "evidence", release=True, release_notes=gate.support_text(policy, "en")) result = json.loads((root / "evidence/result.json").read_text()) - assert result["mode"] == "fresh-install-only" and result["cases"] == [] + assert result["status"] == "failed" + assert len(result["cases"]) == 3 + assert all(case["status"] == "passed" for case in result["cases"]) assert result["target_wheel_sha256"] == gate.sha256(wheel) @@ -106,8 +114,9 @@ def test_schema_change_needs_a_new_compatibility_review(release): def test_supported_mode_cannot_succeed_with_an_empty_matrix(release): root, _, policy = release policy["mode"] = "supported-upgrades" + policy["automatic_sources"] = None (root / gate.MANIFEST).write_text(json.dumps(policy)) - with pytest.raises(gate.GateError, match="matrix disagree"): + with pytest.raises(gate.GateError, match="nonempty matrix"): gate.load_policy(root) @@ -130,17 +139,16 @@ def test_schema_owners_outside_persistence_need_compatibility_review(release, re def supported_case(root, policy): - suite = root / "tests/release_upgrades/test_example.py" - suite.parent.mkdir(parents=True) - suite.write_text("# This fixture is only used to exercise gate failures.\n") policy["mode"] = "supported-upgrades" + policy["automatic_sources"] = None policy["cases"] = [ { "source_version": "1.3.0", - "backend": "sqlite", + "backend": backend, "configuration": "full-server", - "suite": suite.relative_to(root).as_posix(), + "suite": "tests/release_upgrades/test_example.py", } + for backend in ("sqlite", "seekdb", "oceanbase") ] (root / gate.MANIFEST).write_text(json.dumps(policy)) gate.check_docs(root, policy, write=True) @@ -157,9 +165,12 @@ def fail(*_args): raise gate.GateError(blocker) monkeypatch.setattr(gate, "run_case", fail) - with pytest.raises(gate.GateError, match=blocker): + with pytest.raises(gate.GateError, match="Required upgrade acceptance failed"): gate.gate(root, wheel, root / "evidence") - assert json.loads((root / "evidence/result.json").read_text())["status"] == "failed" + evidence = json.loads((root / "evidence/result.json").read_text()) + assert evidence["status"] == "failed" + assert len(evidence["cases"]) == 3 + assert all(case["status"] == "failed" and blocker in case["error"] for case in evidence["cases"]) def test_missing_suite_cannot_be_advertised(release): @@ -240,7 +251,11 @@ def automatic_policy(policy): "strategy": "fixed-version", "source_version": "1.3.0", "profiles": [ - {"backend": backend, "configuration": "full-server-no-vector", "suite": None} + { + "backend": backend, + "configuration": "full-server-no-vector", + "suite": "tests/release_upgrades/test_example.py", + } for backend in ("sqlite", "seekdb", "oceanbase") ], } @@ -257,7 +272,7 @@ def test_fixed_source_tracks_each_target_on_all_three_backends(release, monkeypa assert [(case["source_version"], case["backend"]) for case in resolved["candidate_cases"]] == [ ("1.3.0", backend) for backend in ("sqlite", "seekdb", "oceanbase") ] - assert resolved["required_cases"] == [] # Candidate selection does not establish upgrade support. + assert resolved["required_cases"] == resolved["candidate_cases"] assert all(case["source_artifact"]["sha256"] == "a" * 64 for case in resolved["candidate_cases"]) @@ -321,7 +336,7 @@ def unavailable(): def test_switching_to_supported_upgrades_requires_all_backend_suites(release): root, _, policy = release automatic_policy(policy) - policy["mode"] = "supported-upgrades" + policy["automatic_sources"]["profiles"][0]["suite"] = None (root / gate.MANIFEST).write_text(json.dumps(policy)) with pytest.raises(gate.GateError, match="every backend profile"): gate.load_policy(root) @@ -383,14 +398,10 @@ def test_upgrade_jobs_require_real_backends_and_always_clean_up(workflow_name, j i for i, step in enumerate(steps) if step.get("uses") == "./.github/actions/setup-upgrade-oceanbase" ) gate_index = next(i for i, step in enumerate(steps) if "run_upgrade_release_gate.py" in step.get("run", "")) - regression_index = next( - i for i, step in enumerate(steps) if "ci_legacy_upgrade_rejection.py" in step.get("run", "") - ) - assert setup_index < gate_index < regression_index - for index in (setup_index, gate_index, regression_index): + assert setup_index < gate_index + for index in (setup_index, gate_index): assert "if" not in steps[index] and not steps[index].get("continue-on-error") - assert "for backend in sqlite seekdb oceanbase; do" in steps[regression_index]["run"] - assert "--respect-policy" in steps[regression_index]["run"] + assert not any("ci_legacy_upgrade_rejection.py" in step.get("run", "") for step in steps) assert "/pc_upgrade_test?" in job["env"]["PC_UPGRADE_OCEANBASE_URL"] assert "/pc_upgrade_fresh?" in job["env"]["PC_UPGRADE_OCEANBASE_FRESH_URL"] cleanup = next(step for step in steps if "docker rm --force upgrade-oceanbase" in step.get("run", "")) @@ -407,3 +418,40 @@ def test_oceanbase_fixture_selects_the_oceanbase_server_profile(tmp_path, monkey settings = ServerSettings() assert settings.database.kind == "oceanbase" assert settings.database.url.get_secret_value() == url + + +@pytest.mark.parametrize("mode", ["fresh-install-only", "upgrade-required", "supported-upgrades"]) +def test_declarations_cannot_bypass_missing_backend_or_suite(release, mode): + root, _, policy = release + policy["mode"] = mode + policy["automatic_sources"]["profiles"] = policy["automatic_sources"]["profiles"][:2] + (root / gate.MANIFEST).write_text(json.dumps(policy)) + with pytest.raises(gate.GateError): + gate.load_policy(root) + + +def test_fresh_install_only_is_not_an_upgrade_escape_hatch(release): + root, _, policy = release + policy["mode"] = "fresh-install-only" + (root / gate.MANIFEST).write_text(json.dumps(policy)) + with pytest.raises(gate.GateError, match="mode"): + gate.load_policy(root) + + +def test_failed_backend_does_not_skip_other_required_backends(release, monkeypatch): + root, wheel, _ = release + executed = [] + + def execute(_root, _wheel, case, _output): + executed.append(case["backend"]) + if case["backend"] == "sqlite": + raise gate.GateError("migration_required") + return {"case": case, "status": "passed"} + + monkeypatch.setattr(gate, "run_case", execute) + with pytest.raises(gate.GateError, match="failed: sqlite"): + gate.gate(root, wheel, root / "evidence") + assert executed == ["sqlite", "seekdb", "oceanbase"] + result = json.loads((root / "evidence/result.json").read_text()) + assert result["status"] == "failed" + assert [case["status"] for case in result["cases"]] == ["failed", "passed", "passed"] From 7941a900ec1b592c8d4a00bbe3e06185195190dc Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Thu, 22 Oct 2026 15:35:59 +0800 Subject: [PATCH 07/11] test(release): use embedded-compatible Server roles --- scripts/upgrade_release_gate.py | 2 ++ tests/release_upgrades/test_full_server.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/upgrade_release_gate.py b/scripts/upgrade_release_gate.py index 0ba593cd7f..7122864f33 100644 --- a/scripts/upgrade_release_gate.py +++ b/scripts/upgrade_release_gate.py @@ -312,6 +312,8 @@ def run_case(root: Path, wheel: Path, case: dict[str, Any], output: Path) -> dic "--confcutdir", str(root / "tests/release_upgrades"), "--strict-markers", + "-p", + "no:cacheprovider", "-o", "xfail_strict=true", "-ra", diff --git a/tests/release_upgrades/test_full_server.py b/tests/release_upgrades/test_full_server.py index 7212eec944..bbd655c5de 100644 --- a/tests/release_upgrades/test_full_server.py +++ b/tests/release_upgrades/test_full_server.py @@ -124,7 +124,7 @@ def environment_for(case, *, fresh=False): "POWERCONTEXT_SERVER_AUTH_TOKEN": TOKEN, "POWERCONTEXT_SERVER_ACCESS_MODE": "enforced", "POWERCONTEXT_SERVER_CURSOR_SIGNING_SECRET": "isolated-upgrade-cursor-signing-key-0001", - "POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_ROLE": "api", + "POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_ROLE": "all", "POWERCONTEXT_SERVER_MCP_ENABLED": "false", "NO_PROXY": "localhost,127.0.0.1", "no_proxy": "localhost,127.0.0.1", From 10efc32f221717944a80da0fc91d228e1e663f36 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Thu, 22 Oct 2026 16:10:23 +0800 Subject: [PATCH 08/11] fix(release): normalize review hashes and validate all artifacts --- .github/workflows/master.yml | 4 +- .github/workflows/release.yml | 2 +- Makefile | 6 +- docs/en/development/releasing.md | 17 ++- docs/zh/development/releasing.md | 14 ++- scripts/run_upgrade_release_gate.py | 122 +++++++++++++++++-- scripts/upgrade_release_gate.py | 6 +- tests/release_upgrades/README.md | 14 ++- tests/test_upgrade_publication.py | 182 ++++++++++++++++++++++++++++ tests/test_upgrade_release_gate.py | 34 ++++++ 10 files changed, 380 insertions(+), 21 deletions(-) create mode 100644 tests/test_upgrade_publication.py diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index eb4ac2e4a6..76f4f12789 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -37,9 +37,9 @@ jobs: - name: Verify upgrade policy and gate regressions run: | make upgrade-policy-check - uv run pytest -q tests/test_upgrade_release_gate.py + uv run pytest -q tests/test_upgrade_release_gate.py tests/test_upgrade_publication.py - name: Build the target artifact - run: uv build --wheel --out-dir dist + run: uv build --out-dir dist - name: Start the isolated upgrade OceanBase environment uses: ./.github/actions/setup-upgrade-oceanbase diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2dddbb90d0..792b68c168 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,7 +110,7 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: release-dists - path: dist/* + path: ${{ runner.temp }}/upgrade-release-evidence/validated-dist/* if-no-files-found: error retention-days: 30 diff --git a/Makefile b/Makefile index 6f968cea9d..19482ef3d3 100644 --- a/Makefile +++ b/Makefile @@ -243,7 +243,7 @@ upgrade-policy-check: ## Verify the declared upgrade matrix, persistence review, @uv run python scripts/upgrade_release_gate.py .PHONY: upgrade-release-test -upgrade-release-test: ## Run installed-release acceptance for the single wheel in dist (fresh-install-only makes no upgrade claim). +upgrade-release-test: ## Validate distributions in dist and require successful installed-release upgrades. @uv run python scripts/run_upgrade_release_gate.py .PHONY: clean-build @@ -253,9 +253,7 @@ clean-build: ## Clean build artifacts .PHONY: publish publish: ## Publish after release upgrade acceptance; UPGRADE_RELEASE_NOTES must name the reviewed release notes file. - @uv run python scripts/run_upgrade_release_gate.py --release --release-notes "$$UPGRADE_RELEASE_NOTES" - @echo "🚀 Publishing." - @uv publish dist/* + @uv run python scripts/run_upgrade_release_gate.py --release --publish --release-notes "$$UPGRADE_RELEASE_NOTES" .PHONY: build-and-publish build-and-publish: build publish ## Build and publish. diff --git a/docs/en/development/releasing.md b/docs/en/development/releasing.md index db765663cc..1f76f902e9 100644 --- a/docs/en/development/releasing.md +++ b/docs/en/development/releasing.md @@ -144,16 +144,29 @@ The suite contract and evidence requirements are in `tests/release_upgrades/READ `make check` and `make release-check` validate this policy and the bilingual installation notices. Changes to managed persistence inputs require a compatibility review with an updated digest and rationale. A digest is a -review trigger, not evidence that a migration is correct. `make version-bump` also updates the policy target; +review trigger, not evidence that a migration is correct. Managed text hashes normalize CRLF to LF so checkout +line endings do not require a new review; distribution hashes always cover the exact archive bytes. `make version-bump` also updates the policy target; regenerate the support notices with `uv run python scripts/upgrade_release_gate.py --write-docs` after matrix changes. -After building exactly one wheel into `dist/`, execute the declared upgrade cases: +Build exactly one wheel and its matching source distribution into `dist/`, then execute the declared upgrade cases: ```bash make upgrade-release-test uv run python scripts/run_upgrade_release_gate.py --release --release-notes /path/to/release-notes.md ``` +The publication input directory must contain exactly one wheel and one matching `.tar.gz` source distribution; +leftover distributions and unexpected files fail validation (`uv`'s `.gitignore` is not an artifact). The runner checks +source metadata and builds a wheel from the source archive, comparing all member contents except the derived +`RECORD` listing. A stale source archive with the same version therefore cannot bypass validation. Archive timestamps +and compression do not affect this comparison. A build-tool change that alters wheel contents requires rebuilding +the complete pair, not ignoring the mismatch. + +The runner tests a private snapshot, records exact artifact SHA-256s in `publication.json`, and exports only passing +files to the evidence directory's `validated-dist/`. CI uploads that set. `make publish` uses `--release --publish` to +upload those exact paths in the same invocation; subsequent changes to the original `dist/` cannot join the upload. +No packages are uploaded by validation commands without `--publish`. + The release check requires the actual wheel version to match the policy and the release notes to include the exact English support declaration from the installation guide. It installs each exact source release and the target wheel in separate environments. Missing tests, skipped backends, failures, and missing evidence block the check. Static diff --git a/docs/zh/development/releasing.md b/docs/zh/development/releasing.md index 6c5af20b7d..4f65ff503b 100644 --- a/docs/zh/development/releasing.md +++ b/docs/zh/development/releasing.md @@ -134,16 +134,26 @@ DSH README 中说明某项能力已包含在 `1.1.0` 的文字同样属于历史 验收契约和证据要求见仓库中的 `tests/release_upgrades/README.md`。 `make check` 和 `make release-check` 会核对策略及双语安装说明。受管持久化文件变化时,必须审查兼容性影响, -更新摘要和理由。摘要用于触发审查,本身不证明迁移正确。`make version-bump` 同时更新策略中的目标版本; +更新摘要和理由。摘要用于触发审查,本身不证明迁移正确。受管文本的哈希会将 CRLF 归一为 LF,避免检出行尾 +差异误报兼容性变更;发行包哈希仍覆盖完整原始字节。`make version-bump` 同时更新策略中的目标版本; 支持矩阵变化后,运行 `uv run python scripts/upgrade_release_gate.py --write-docs` 更新安装说明中的支持范围。 -将唯一的目标 wheel 构建到 `dist/` 后,执行声明的升级组合验收: +将唯一的目标 wheel 和配套源码包构建到 `dist/` 后,执行声明的升级组合验收: ```bash make upgrade-release-test uv run python scripts/run_upgrade_release_gate.py --release --release-notes /path/to/release-notes.md ``` +发布输入目录必须只有一个 wheel 和一个配套的 `.tar.gz` 源码包;残留发行包和其他文件都会使检查失败, +`uv` 生成的 `.gitignore` 不作为发行产物。门禁检查源码包元数据,并从该源码包实际重建 wheel,逐项比较归档中的 +文件内容,仅排除派生的 `RECORD` 哈希清单。因此,同版本但内容过期的源码包也无法绕过检查。归档时间戳和压缩 +方式不影响比较;构建工具变化导致内容不一致时,需要重新构建整组产物,不能忽略差异。 + +门禁验收输入文件的独立副本,在 `publication.json` 中记录实际 SHA-256,并且只有全部通过才将文件输出到证据目录 +下的 `validated-dist/`,供 CI 上传。`make publish` 使用 `--release --publish` 在同一次调用中上传这些确切路径; +验收期间原 `dist/` 的变化不会混入上传。未指定 `--publish` 的验收命令不会发布软件包。 + 发布检查要求实际 wheel 版本与策略一致,发布说明包含安装指南中的完整英文支持声明。每个组合分别安装准确的 旧版发行包和目标 wheel。测试缺失、后端跳过、执行失败或证据缺失都会阻断检查。仅通过静态策略检查不表示 存在升级路径。普通 PR 构建允许使用开发版本;`--release` 必须验证最终 tag 构建的 wheel,不能覆盖版本号。 diff --git a/scripts/run_upgrade_release_gate.py b/scripts/run_upgrade_release_gate.py index b585530f15..6dd7467d66 100644 --- a/scripts/run_upgrade_release_gate.py +++ b/scripts/run_upgrade_release_gate.py @@ -12,14 +12,118 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Select the built wheel and run the same upgrade gate locally and in CI.""" +"""Validate the complete distribution set and gate its publication on upgrades.""" import argparse +import hashlib import json +import shutil +import subprocess +import tarfile import tempfile +import zipfile +from email.parser import BytesParser from pathlib import Path -from upgrade_release_gate import ROOT, GateError, gate, require +from upgrade_release_gate import ROOT, GateError, gate, require, sha256, wheel_version + + +def wheel_contents(wheel: Path) -> dict[str, str]: + """Compare installed payloads, excluding only the derived RECORD hash listing.""" + with zipfile.ZipFile(wheel) as archive: + names = [item.filename for item in archive.infolist() if not item.is_dir()] + require(len(set(names)) == len(names), "Duplicate wheel members") + return { + name: hashlib.sha256(archive.read(name)).hexdigest() + for name in names + if not name.endswith(".dist-info/RECORD") + } + + +def validate_sdist(sdist: Path, wheel: Path, work: Path) -> None: + expected = wheel_version(wheel) + require(sdist.name == f"powercontext-{expected}.tar.gz", "Source distribution version differs from the wheel") + with tarfile.open(sdist, "r:gz") as archive: + names = [item for item in archive.getmembers() if item.name == f"powercontext-{expected}/PKG-INFO"] + require(len(names) == 1 and names[0].isfile(), "Source distribution requires one root PKG-INFO") + stream = archive.extractfile(names[0]) + if stream is None: + raise GateError("Cannot read source distribution metadata") # noqa: TRY003 -- operator diagnostic + metadata = BytesParser().parsebytes(stream.read()) + require( + metadata["Name"] == "powercontext" and metadata["Version"] == expected, + "Source distribution metadata differs from the wheel", + ) + rebuilt = work / "rebuilt" + rebuilt.mkdir() + with (work / "sdist-build.log").open("w", encoding="utf-8") as log: + subprocess.run( # noqa: S603 -- reviewed local distribution and fixed build tool; no shell + ["uv", "build", "--wheel", str(sdist), "--out-dir", str(rebuilt)], # noqa: S607 -- project tool on PATH + stdout=log, + stderr=subprocess.STDOUT, + timeout=600, + check=True, + ) + wheels = list(rebuilt.glob("*.whl")) + require(len(wheels) == 1, "Source distribution must build exactly one wheel") + require(wheels[0].name == wheel.name, "Source distribution produces a different wheel filename") + require( + wheel_contents(wheels[0]) == wheel_contents(wheel), + "Source distribution builds different wheel contents; rebuild the complete publication set", + ) + + +def stage_distributions(dist: Path, staging: Path, *, release: bool) -> tuple[Path, list[Path]]: + """Freeze all publication inputs before testing; later dist changes cannot be uploaded.""" + files = sorted(path for path in dist.iterdir() if path.name != ".gitignore") + require( + all(path.is_file() and not path.is_symlink() for path in files), "Distribution inputs must be regular files" + ) + wheels = [path for path in files if path.suffix == ".whl"] + sources = [path for path in files if path.name.endswith(".tar.gz")] + require(len(wheels) == 1, "Build exactly one release wheel before running upgrade acceptance") + require(len(sources) <= 1, "Build at most one source distribution") + require(set(files) == set(wheels + sources), "Unexpected files in publication directory") + require(not release or len(sources) == 1, "Release publication requires one wheel and one source distribution") + staging.mkdir() + staged = [] + for path in files: + copied = staging / path.name + shutil.copyfile(path, copied) + staged.append(copied) + wheel = staging / wheels[0].name + if sources: + validate_sdist(staging / sources[0].name, wheel, staging.parent) + return wheel, staged + + +def accept_distributions(dist: Path, output: Path, *, release: bool, release_notes: str | None) -> list[Path]: + # A private snapshot prevents a leftover or concurrent dist write from joining + # publication after acceptance. Keep the same bytes through testing and upload. + require(not output.exists(), "Use a fresh evidence directory") + with tempfile.TemporaryDirectory(prefix="pc-release-inputs-") as temporary: + work = Path(temporary) + try: + wheel, files = stage_distributions(dist, work / "inputs", release=release) + expected = {path.name: sha256(path) for path in files} + gate(ROOT, wheel, output, release=release, release_notes=release_notes) + require({path.name: sha256(path) for path in files} == expected, "Distribution changed during acceptance") + validated = output / "validated-dist" + validated.mkdir() + accepted = [] + for path in files: + destination = validated / path.name + shutil.copyfile(path, destination) + require(sha256(destination) == expected[path.name], "Validated distribution copy changed") + accepted.append(destination) + (output / "publication.json").write_text( + json.dumps({"artifacts": expected}, indent=2) + "\n", encoding="utf-8" + ) + return accepted + finally: + if (work / "sdist-build.log").is_file(): + output.mkdir(parents=True, exist_ok=True) + shutil.copyfile(work / "sdist-build.log", output / "sdist-build.log") def main() -> None: @@ -27,20 +131,22 @@ def main() -> None: parser.add_argument("--dist", type=Path, default=Path("dist")) parser.add_argument("--output", type=Path) parser.add_argument("--release", action="store_true") + parser.add_argument( + "--publish", action="store_true", help="Upload only this run's validated files; requires --release" + ) notes = parser.add_mutually_exclusive_group() notes.add_argument("--release-event", type=Path) notes.add_argument("--release-notes", type=Path) args = parser.parse_args() try: - wheels = list(args.dist.glob("*.whl")) - require(len(wheels) == 1, "Build exactly one release wheel before running upgrade acceptance") + require(not args.publish or args.release, "--publish requires --release") release_notes = None if args.release_event: release_notes = json.loads(args.release_event.read_text(encoding="utf-8"))["release"]["body"] or "" elif args.release_notes: release_notes = args.release_notes.read_text(encoding="utf-8") - output = args.output or Path(tempfile.mkdtemp(prefix="pc-upgrade-evidence-")) / "report" - gate(ROOT, wheels[0].resolve(), output.resolve(), release=args.release, release_notes=release_notes) + output = (args.output or Path(tempfile.mkdtemp(prefix="pc-upgrade-evidence-")) / "report").resolve() + accepted = accept_distributions(args.dist.resolve(), output, release=args.release, release_notes=release_notes) result = json.loads((output / "result.json").read_text(encoding="utf-8")) print( f"Upgrade gate passed: {result['mode']}; " @@ -48,7 +154,9 @@ def main() -> None: f"{len(result['cases'])} accepted upgrade combinations. " f"Evidence: {output / 'result.json'}" ) - except (GateError, OSError, ValueError) as error: + if args.publish: + subprocess.run(["uv", "publish", *map(str, accepted)], check=True) # noqa: S603, S607 -- project tool, exact validated files + except (GateError, OSError, ValueError, subprocess.SubprocessError, tarfile.TarError, zipfile.BadZipFile) as error: raise SystemExit(f"Upgrade gate failed: {error}") from error # noqa: TRY003 -- operator diagnostic diff --git a/scripts/upgrade_release_gate.py b/scripts/upgrade_release_gate.py index 7122864f33..b2255ad72e 100644 --- a/scripts/upgrade_release_gate.py +++ b/scripts/upgrade_release_gate.py @@ -76,10 +76,11 @@ def managed_digest(root: Path) -> str: This deliberately over-approximates schema changes. It is a review trigger, not a claim to prove SQL or data-format compatibility from Python syntax. + Normalize checkout line endings; artifact hashes must retain their exact bytes. """ inputs = [root / relative for relative in MANAGED_INPUTS] files = { - path.relative_to(root).as_posix(): sha256(path) + path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes().replace(b"\r\n", b"\n")).hexdigest() for location in inputs for path in (location.rglob("*") if location.is_dir() else [location]) if path.is_file() and path.suffix in {".py", ".sql", ".json"} @@ -107,7 +108,8 @@ def load_policy(root: Path) -> dict[str, Any]: isinstance(review["rationale"], str) and review["rationale"].strip(), "Schema compatibility rationale required" ) require( - review["persistence_sha256"] == managed_digest(root), "Persistence changed: update the compatibility review" + review["persistence_sha256"] == managed_digest(root), + "Persistence changed (file set or content; CRLF/LF normalized): update the compatibility review", ) cases = policy["cases"] require(isinstance(cases, list), "Upgrade cases must be a list") diff --git a/tests/release_upgrades/README.md b/tests/release_upgrades/README.md index a30d9d1b9b..d5344831a2 100644 --- a/tests/release_upgrades/README.md +++ b/tests/release_upgrades/README.md @@ -93,7 +93,7 @@ Server identity and authorization, and shared limits used in table definitions. `MANAGED_INPUTS` when introducing them. The digest intentionally includes non-schema code: it forces a review of changes, but does not infer semantic compatibility or replace real upgrade tests. -Build exactly one wheel into `dist/`, then run `make upgrade-release-test`. Before release, additionally run +Build one wheel and its matching `.tar.gz` source distribution into `dist/`, then run `make upgrade-release-test`. Before release, additionally run `uv run python scripts/run_upgrade_release_gate.py --release --release-notes /path/to/notes.md`. The notes must contain the exact English support declaration generated in the installation guide. The release workflow performs this check against the GitHub event's notes and gates both PyPI publication and release assets on the same tested wheel. @@ -108,3 +108,15 @@ not the earlier manual creation of a GitHub Release; run the prepublication comm workflows do not invoke it as upgrade acceptance. An expected rejection or its `--respect-policy` result can never replace a successful positive upgrade. The gate attempts every required backend even after an earlier case fails, then fails overall and archives each case's logs and result. Empty matrices and missing suites are errors. + +## Publication artifacts + +The runner validates every distribution input. Publication requires exactly one wheel and one source archive with +matching names and metadata. It rebuilds the source archive with `uv build --wheel` and compares wheel member bytes, +excluding only the derived `RECORD` file. A same-version stale source archive fails just like an older-version one. +PRs may test a wheel alone, but the PR workflow builds both formats to exercise this validation too. + +Acceptance uses a private copy of those files. Only a passing run creates `validated-dist/` and `publication.json` +in its evidence directory. CI uploads that directory; local `make publish` calls the runner with `--release --publish` +and uploads its exact returned paths, never a fresh glob of `dist/`. Failed acceptance produces no publication set. +Managed persistence hashes normalize CRLF/LF checkout line endings; artifact SHA-256s remain byte-exact. diff --git a/tests/test_upgrade_publication.py b/tests/test_upgrade_publication.py new file mode 100644 index 0000000000..3065054d84 --- /dev/null +++ b/tests/test_upgrade_publication.py @@ -0,0 +1,182 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Exercise publication inputs with actual offline sdist builds; intercept uploads.""" + +import io +import json +import sys +import tarfile +import zipfile +from pathlib import Path + +import pytest +import run_upgrade_release_gate as runner +from upgrade_release_gate import GateError, sha256 + + +def write_wheel(path, content=b"current implementation", *, version="2.0.0"): + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("powercontext/__init__.py", content) + archive.writestr(f"powercontext-{version}.dist-info/METADATA", f"Name: powercontext\nVersion: {version}\n") + archive.writestr(f"powercontext-{version}.dist-info/WHEEL", "Wheel-Version: 1.0\nTag: py3-none-any\n") + archive.writestr(f"powercontext-{version}.dist-info/RECORD", "") + + +def write_source(path, content=b"current implementation", *, version="2.0.0", metadata_version=None): + # No external build dependencies or network. uv invokes a real local PEP 517 + # backend so stale same-version source changes alter the rebuilt wheel. + backend = f"""import pathlib, zipfile + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + name = "powercontext-{version}-py3-none-any.whl" + with zipfile.ZipFile(pathlib.Path(wheel_directory) / name, "w") as archive: + archive.writestr("powercontext/__init__.py", pathlib.Path("payload.py").read_bytes()) + archive.writestr("powercontext-{version}.dist-info/METADATA", "Name: powercontext\\nVersion: {version}\\n") + archive.writestr("powercontext-{version}.dist-info/WHEEL", "Wheel-Version: 1.0\\nTag: py3-none-any\\n") + archive.writestr("powercontext-{version}.dist-info/RECORD", "") + return name +""" + files = { + "PKG-INFO": f"Name: powercontext\nVersion: {metadata_version or version}\n".encode(), + "pyproject.toml": b'[build-system]\nrequires = []\nbuild-backend = "fixture_backend"\nbackend-path = ["."]\n', + "fixture_backend.py": backend.encode(), + "payload.py": content, + } + with tarfile.open(path, "w:gz") as archive: + for name, data in files.items(): + member = tarfile.TarInfo(f"powercontext-{version}/{name}") + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + + +@pytest.fixture +def publication(tmp_path, monkeypatch): + monkeypatch.setenv("UV_OFFLINE", "true") + dist = tmp_path / "dist" + dist.mkdir() + wheel = dist / "powercontext-2.0.0-py3-none-any.whl" + source = dist / "powercontext-2.0.0.tar.gz" + write_wheel(wheel) + write_source(source) + (dist / ".gitignore").write_text("*") + return dist, wheel, source, tmp_path / "evidence" + + +@pytest.mark.parametrize( + "invalid", ["old-version", "metadata-mismatch", "same-version-stale-content", "extra-file", "missing-sdist"] +) +def test_invalid_publication_set_never_reaches_acceptance(publication, monkeypatch, invalid): + dist, _wheel, source, output = publication + if invalid == "old-version": + source.unlink() + write_source(dist / "powercontext-1.3.0.tar.gz", version="1.3.0") + elif invalid == "metadata-mismatch": + write_source(source, metadata_version="1.3.0") + elif invalid == "same-version-stale-content": + write_source(source, b"old implementation") + elif invalid == "extra-file": + (dist / "leftover.zip").write_bytes(b"unchecked archive") + else: + source.unlink() + + def unexpected_gate(*_args, **_kwargs): + pytest.fail("Invalid publication inputs must fail before upgrade acceptance") + + monkeypatch.setattr(runner, "gate", unexpected_gate) + with pytest.raises(GateError): + runner.accept_distributions(dist, output, release=True, release_notes="fixture") + assert not (output / "validated-dist").exists() + + +def test_failed_upgrade_cannot_produce_publishable_files(publication, monkeypatch): + dist, _, _, output = publication + + def fail(*_args, **_kwargs): + raise GateError("migration_required") + + monkeypatch.setattr(runner, "gate", fail) + with pytest.raises(GateError, match="migration_required"): + runner.accept_distributions(dist, output, release=True, release_notes="fixture") + assert not (output / "validated-dist").exists() + + +def test_publish_uses_only_tested_snapshot_even_if_dist_changes(publication, monkeypatch): + dist, wheel, source, output = publication + expected = {path.name: sha256(path) for path in (wheel, source)} + notes = dist.parent / "notes.md" + notes.write_text("fixture release notes") + + def accept(_root, tested_wheel, evidence, **_kwargs): + assert sha256(tested_wheel) == expected[wheel.name] + # A concurrent build must not replace bytes being tested or introduce an + # unchecked package into the later upload glob. + write_wheel(wheel, b"replacement after validation") + write_source(source, b"replacement after validation") + write_source(dist / "powercontext-1.3.0.tar.gz", version="1.3.0") + evidence.mkdir() + (evidence / "result.json").write_text( + json.dumps({ + "mode": "supported-upgrades", + "resolved_matrix": {"candidate_cases": [1, 2, 3]}, + "cases": [1, 2, 3], + }) + ) + + monkeypatch.setattr(runner, "gate", accept) + original_run = runner.subprocess.run + uploads = [] + + def intercept_upload(command, **kwargs): + if command[:2] == ["uv", "publish"]: + uploads.extend(Path(name) for name in command[2:]) + return None + return original_run(command, **kwargs) + + monkeypatch.setattr(runner.subprocess, "run", intercept_upload) + monkeypatch.setattr( + sys, + "argv", + [ + "run_upgrade_release_gate.py", + "--dist", + str(dist), + "--output", + str(output), + "--release", + "--publish", + "--release-notes", + str(notes), + ], + ) + runner.main() + assert {path.name: sha256(path) for path in uploads} == expected + assert all(path.parent == output / "validated-dist" for path in uploads) + assert json.loads((output / "publication.json").read_text())["artifacts"] == expected + + +def test_wheel_only_pr_build_still_runs_upgrade_acceptance(publication, monkeypatch): + dist, wheel, source, output = publication + source.unlink() + tested = [] + + def accept(_root, tested_wheel, evidence, **kwargs): + tested.append(sha256(tested_wheel)) + assert not kwargs["release"] + evidence.mkdir() + + monkeypatch.setattr(runner, "gate", accept) + accepted = runner.accept_distributions(dist, output, release=False, release_notes=None) + assert tested == [sha256(wheel)] + assert [path.name for path in accepted] == [wheel.name] diff --git a/tests/test_upgrade_release_gate.py b/tests/test_upgrade_release_gate.py index 66a77dc776..49fc331039 100644 --- a/tests/test_upgrade_release_gate.py +++ b/tests/test_upgrade_release_gate.py @@ -376,6 +376,7 @@ def test_publishing_jobs_depend_on_nonoptional_preupload_upgrade_gate(): assert "--release --release-event" in steps[index]["run"] upload = next(i for i, step in enumerate(steps) if step.get("with", {}).get("name") == "release-dists") assert index < upload + assert steps[upload]["with"]["path"] == "${{ runner.temp }}/upgrade-release-evidence/validated-dist/*" for name in ("pypi-publish", "release-assets"): assert workflow["jobs"][name]["needs"] == "release-build" @@ -455,3 +456,36 @@ def execute(_root, _wheel, case, _output): result = json.loads((root / "evidence/result.json").read_text()) assert result["status"] == "failed" assert [case["status"] for case in result["cases"]] == ["failed", "passed", "passed"] + + +@pytest.mark.parametrize( + "relative", + [ + "src/powercontext/builtin/persistence/tables.py", + "src/powercontext/builtin/persistence/revision.sql", + "src/powercontext/builtin/code/layout.json", + "src/powercontext/server/authz/repository.py", + "src/powercontext/server/identity.py", + "src/powercontext/limits.py", + ], +) +def test_checkout_line_endings_preserve_compatibility_review(release, relative): + root, _, policy = release + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"first line\nsecond line\n") + policy["schema_review"]["persistence_sha256"] = gate.managed_digest(root) + (root / gate.MANIFEST).write_text(json.dumps(policy)) + path.write_bytes(b"first line\r\nsecond line\r\n") + assert gate.load_policy(root)["schema_review"] == policy["schema_review"] + path.write_bytes(b"first line\r\nchanged content\r\n") + with pytest.raises(gate.GateError, match="file set or content; CRLF/LF normalized"): + gate.load_policy(root) + + +def test_artifact_hashes_do_not_normalize_bytes(tmp_path): + artifact = tmp_path / "artifact" + artifact.write_bytes(b"payload\n") + before = gate.sha256(artifact) + artifact.write_bytes(b"payload\r\n") + assert gate.sha256(artifact) != before From 9f7886894489aac5e753af5146554fb07be527a3 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Sat, 24 Oct 2026 12:58:41 +0800 Subject: [PATCH 09/11] fix(ci): preserve schema literals and verify viewer permissions --- .github/workflows/master.yml | 2 +- tests/release_upgrades/README.md | 11 ++- .../release_upgrades/authorization_fixture.py | 73 ++++++++++++++++ tests/release_upgrades/test_full_server.py | 76 ++++++++++++++--- tests/test_upgrade_schema_equivalence.py | 84 +++++++++++++++++++ 5 files changed, 231 insertions(+), 15 deletions(-) create mode 100644 tests/release_upgrades/authorization_fixture.py create mode 100644 tests/test_upgrade_schema_equivalence.py diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 76f4f12789..3b7f91f04b 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -37,7 +37,7 @@ jobs: - name: Verify upgrade policy and gate regressions run: | make upgrade-policy-check - uv run pytest -q tests/test_upgrade_release_gate.py tests/test_upgrade_publication.py + uv run pytest -q tests/test_upgrade_release_gate.py tests/test_upgrade_publication.py tests/test_upgrade_schema_equivalence.py - name: Build the target artifact run: uv build --out-dir dist - name: Start the isolated upgrade OceanBase environment diff --git a/tests/release_upgrades/README.md b/tests/release_upgrades/README.md index d5344831a2..39289f177a 100644 --- a/tests/release_upgrades/README.md +++ b/tests/release_upgrades/README.md @@ -60,11 +60,16 @@ Provide these behavioral tests (additional tests are allowed): | `test_public_migration_commands` | Stop writers, run the target artifact's public maintenance commands, and validate full readiness. A limited bundle verification does not pass. | | `test_target_startup` | Start the target Server against the migrated database and verify readiness. | | `test_read_write_and_retrieval` | Read the old records, write new ones, and retrieve both through public interfaces. | -| `test_authorization` | Verify permitted and denied access and preservation of relevant grants. | +| `test_authorization` | Preserve the old viewer grant; verify its allowed reads, denied cross-scope reads and denied writes with both installed releases. Invalid credentials must still fail. | | `test_candidates_and_processing` | Verify retained Candidates and CAS approval; load and acknowledge the old pending intent using the target installed SDK, including idempotency. This does not execute a model-driven Worker. | | `test_preserved_history_and_cursors` | Verify exact history/references and unchanged or declared-transformed cursor and receipt semantics. | | `test_fresh_upgrade_equivalence` | Initialize a fresh target database; compare schema/backend objects and behavior with the upgraded database. The current suite has no retained/archive object exclusions. | +The authorization probe runs the installed Server through its native SDK and real HTTP handlers with a fixed +ordinary-user authentication provider. Authorization uses the persisted builtin policy without overrides. The CLI +Server is stopped before each SDK probe so embedded seekDB has only one owning process. CLI startup remains a +separate mandatory acceptance phase. + Use a session fixture for the shared lifecycle; do not depend on pytest's test execution order. All collected tests must execute and pass. Empty collection, missing phases, skips (including missing backends), xfail, failure, command failure, and missing/malformed reports fail the gate. A real OceanBase suite must provision an isolated database and @@ -82,8 +87,8 @@ they follow actual installed operations and compare persisted data. The profile not vector storage or model-generated content. The processing fixture tests persisted intent compatibility and acknowledgment, not Worker execution. Add separately reviewed profiles before claiming those optional paths. SQLite inventories include indexes, views and triggers; MySQL-family table DDL includes indexes and constraints, -and views are collected separately. Comparison normalizes whitespace, identifier quoting and auto-increment -counters only; structural differences fail rather than being silently excluded. +and views are collected separately. Comparison tokenizes DDL while preserving quoted tokens and comments verbatim, +and ignores only unquoted whitespace and table-level auto-increment counters; structural differences fail rather than being silently excluded. Regenerate installation support regions with `uv run python scripts/upgrade_release_gate.py --write-docs` and run `make upgrade-policy-check`. When persistence diff --git a/tests/release_upgrades/authorization_fixture.py b/tests/release_upgrades/authorization_fixture.py new file mode 100644 index 0000000000..3ef4d22855 --- /dev/null +++ b/tests/release_upgrades/authorization_fixture.py @@ -0,0 +1,73 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Check real HTTP authorization with each installed release's native SDK. + +Run with -I after stopping the CLI Server. Only authentication is supplied; +the installed Server loads and enforces the persisted builtin access policy. +""" + +import asyncio +import json +import sys +from pathlib import Path + +import httpx + +from powercontext.server.authentication import StaticBearerAuthenticationProvider +from powercontext.server.authz import PrincipalRef +from powercontext.server.factory import create_server_app +from powercontext.server.settings import ServerSettings + + +async def verify(historical): + token = "isolated-viewer-token" # noqa: S105 -- disposable fixture credential + viewer = PrincipalRef(type="user", id="upgrade-viewer") + app = create_server_app( + settings=ServerSettings(), + authentication_provider=StaticBearerAuthenticationProvider(token, viewer), + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://fixture", + headers={"Authorization": f"Bearer {token}"}, + ) as client, + ): + identity = await client.get("/v1/access/me") + assert identity.status_code == 200, identity.text + assert identity.json()["principal"]["type"] == "user" + assert identity.json()["principal"]["id"] == viewer.id + scope = historical["scope"] + allowed = await client.get(f"/v1/scopes/{scope}") + assert allowed.status_code == 200, allowed.text + records = await client.post("/v1/memory/entries/list", json={"scope_id": scope}) + assert records.status_code == 200, records.text + assert {item["text"] for item in records.json()["entries"]} >= {item["text"] for item in historical["memory"]} + denied_read = await client.get(f"/v1/scopes/{historical['ungranted_scope']}") + assert denied_read.status_code == 403, denied_read.text + denied_write = await client.post( + "/v1/memory/remember", + json={"scope_id": scope, "kind": "fact", "text": "Viewer must not persist this write."}, + ) + assert denied_write.status_code == 403, denied_write.text + after = await client.post("/v1/memory/entries/list", json={"scope_id": scope}) + assert after.status_code == 200, after.text + assert after.json() == records.json(), "Denied writes must not change stored records" + print("Viewer can read its granted scope; cross-scope reads and writes are denied.") + + +if __name__ == "__main__": + asyncio.run(verify(json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")))) diff --git a/tests/release_upgrades/test_full_server.py b/tests/release_upgrades/test_full_server.py index bbd655c5de..047e064c53 100644 --- a/tests/release_upgrades/test_full_server.py +++ b/tests/release_upgrades/test_full_server.py @@ -187,6 +187,17 @@ def task_state(case, role, operation, scope): return json.loads(path.read_text()) +def verify_authorization(case, role, historical): + fixture = case["report"] / "authorization-fixture.json" + fixture.write_text(json.dumps(historical), encoding="utf-8") + command( + [str(case[role]), "-I", str(ROOT / "tests/release_upgrades/authorization_fixture.py"), str(fixture)], + environment_for(case), + case["work"], + case["report"] / f"{role}-authorization.log", + ) + + def seed(client, *, candidate_prefix): scopes = [ post( @@ -240,9 +251,8 @@ def seed(client, *, candidate_prefix): {"scope_id": scope, "candidate_id": published["candidate_id"], "expected_version": published["version"]}, ) artifact = post(client, "/v1/experience/get", {"scope_id": scope, "artifact": published["result_artifact"]}) - principal = client.get("/v1/access/me").json()["principal"] grant = { - "subject": principal, + "subject": {"type": "user", "id": "upgrade-viewer"}, "resource": {"type": "scope", "scope_id": scope}, "role": "scope.viewer", "idempotency_key": "upgrade-viewer", @@ -257,6 +267,7 @@ def seed(client, *, candidate_prefix): assert {digest(item) for item in readback} >= {digest(item) for item in memory} return { "scope": scope, + "ungranted_scope": scopes[1]["scope_id"], "memory": memory, "candidate": candidate, "first_candidate": first, @@ -289,6 +300,7 @@ def historical(case): assert not snapshot(case, "target", "fresh-empty", fresh=True)["tables"] with server(case["source"], environment_for(case), case["work"], case["report"] / "source-server.log") as client: value = seed(client, candidate_prefix="/v1/artifact-candidates") + verify_authorization(case, "source", value) value["task"] = task_state(case, "source", "seed", value["scope"]) value["schema"] = snapshot(case, "source", "source") assert {"pc_artifact_candidate_heads", "pc_artifact_candidate_versions", "pc_scopes", "pc_artifacts"} <= set( @@ -376,12 +388,17 @@ def test_read_write_and_retrieval(target, historical): assert texts >= {text, *(item["text"] for item in historical["memory"])} -def test_authorization(target, historical): - assert target.get("/v1/scopes", headers={"Authorization": "Bearer invalid-fixture-token"}).status_code == 401 - # The stable idempotency key must resolve to the SAME persisted grant. - assert post(target, "/v1/access/bindings/create", historical["grant"]) == historical["binding"] - response = target.get(f"/v1/scopes/{historical['scope']}") - assert response.status_code == 200 +def test_authorization(case, migrated): + historical = migrated["historical"] + with server( + case["target"], environment_for(case), case["work"], case["report"] / "authorization-server.log" + ) as client: + assert client.get("/v1/scopes", headers={"Authorization": "Bearer invalid-fixture-token"}).status_code == 401 + # The stable idempotency key must resolve to the SAME persisted grant. + assert post(client, "/v1/access/bindings/create", historical["grant"]) == historical["binding"] + # Stop the CLI before opening the same database with the installed SDK, + # especially for the embedded engine's exclusive process ownership. + verify_authorization(case, "target", historical) def test_candidates_and_processing(case, migrated): @@ -425,11 +442,48 @@ def test_preserved_history_and_cursors(target, historical): assert stored[key] == version[key] +# Quoted tokens and comments are opaque: whitespace, quote characters and +# AUTO_INCREMENT text inside them are schema content, not formatting. +DDL_TOKENS = re.compile( + r"""'(?:''|\\.|[^'\\])*' | "(?:""|\\.|[^"\\])*" | `(?:``|\\.|[^`\\])*` + | \[(?:\]\]|[^\]])*\] | /\*.*?\*/ | --[^\r\n]* + | [A-Za-z_\u0080-\U0010ffff][A-Za-z_0-9$\u0080-\U0010ffff]* | [0-9]+ | ->> | -> | <= | >= | <> | != | \|\| | << | >> | [^\s] + """, + re.VERBOSE | re.DOTALL, +) + + def canonical_schema(snapshot): def normalize(sql): - sql = re.sub(r"\bAUTO_INCREMENT=\d+", "", sql) - sql = re.sub(r'["`]([A-Za-z_][A-Za-z_0-9]*)["`]', r"\1", sql) - return " ".join(sql.split()) + tokens = DDL_TOKENS.findall(sql) + table = [lexeme.upper() for lexeme in tokens[:2]] == ["CREATE", "TABLE"] + depth = 0 + closed_definition = False + normalized = [] + index = 0 + while index < len(tokens): + lexeme = tokens[index] + # Only ignore the table-level next-id counter after the definition. + # Column AUTO_INCREMENT attributes and all quoted contents remain. + if ( + table + and closed_definition + and depth == 0 + and lexeme.upper() == "AUTO_INCREMENT" + and tokens[index + 1 : index + 2] == ["="] + and index + 2 < len(tokens) + and tokens[index + 2].isdigit() + ): + index += 3 + continue + if lexeme == "(": + depth += 1 + elif lexeme == ")": + depth -= 1 + closed_definition = depth == 0 + normalized.append(lexeme) + index += 1 + return tuple(normalized) return { kind: {name: normalize(sql or "") for name, sql in snapshot.get(kind, {}).items()} diff --git a/tests/test_upgrade_schema_equivalence.py b/tests/test_upgrade_schema_equivalence.py new file mode 100644 index 0000000000..037ce2dcf2 --- /dev/null +++ b/tests/test_upgrade_schema_equivalence.py @@ -0,0 +1,84 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Prevent schema equivalence from erasing differences in SQL literals.""" + +import sqlite3 + +import pytest + +from tests.release_upgrades.test_full_server import canonical_schema + + +def schema(sql): + return canonical_schema({"tables": {"example": sql}}) + + +@pytest.mark.parametrize( + "left,right", [("a b", "a b"), ('"word"', "word"), ("AUTO_INCREMENT=12", "AUTO_INCREMENT=99")] +) +def test_distinct_sqlite_defaults_do_not_compare_equal(left, right): + inventories = [] + values = [] + for default in (left, right): + with sqlite3.connect(":memory:") as connection: + connection.execute(f"CREATE TABLE example (value TEXT DEFAULT '{default}')") + ddl = connection.execute("SELECT sql FROM sqlite_schema WHERE name='example'").fetchone()[0] + connection.execute("INSERT INTO example DEFAULT VALUES") + values.append(connection.execute("SELECT value FROM example").fetchone()[0]) + inventories.append(schema(ddl)) + assert values[0] != values[1] + assert inventories[0] != inventories[1] + + +def test_distinct_sqlite_checks_do_not_compare_equal(): + inventories = [] + for literal in ("a b", "a b"): + with sqlite3.connect(":memory:") as connection: + connection.execute(f"CREATE TABLE example (value TEXT CHECK (value = '{literal}'))") + connection.execute("INSERT INTO example VALUES (?)", (literal,)) + with pytest.raises(sqlite3.IntegrityError): + connection.execute("INSERT INTO example VALUES (?)", ("a b" if literal == "a b" else "a b",)) + inventories.append( + schema(connection.execute("SELECT sql FROM sqlite_schema WHERE name='example'").fetchone()[0]) + ) + assert inventories[0] != inventories[1] + + +def test_table_counters_and_unquoted_whitespace_are_not_schema_changes(): + left = "CREATE TABLE `example` (`id` int AUTO_INCREMENT, `value` text DEFAULT 'a b') AUTO_INCREMENT=12" + right = "CREATE TABLE `example` ( `id` int AUTO_INCREMENT, `value` text DEFAULT 'a b' ) AUTO_INCREMENT = 99" + assert schema(left) == schema(right) + assert schema(left) != schema(left.replace("int AUTO_INCREMENT", "int")) + + +@pytest.mark.parametrize("literal", [r"'a\' b'", "'it''s quoted'", '"a b"', "`a b`", "[a b]"]) +def test_quoted_tokens_preserve_spaces_and_escapes(literal): + ddl = f"CREATE TABLE example (value TEXT DEFAULT {literal})" + assert schema(ddl) != schema(ddl.replace(" ", " ")) + + +@pytest.mark.parametrize("name", ["字段", "😀😀"]) +def test_unquoted_identifier_boundaries_are_preserved(name): + inventories = [] + columns = [] + for declaration in (name, " ".join(name)): + with sqlite3.connect(":memory:") as connection: + connection.execute(f"CREATE TABLE example ({declaration} TEXT)") + columns.append(connection.execute("PRAGMA table_info(example)").fetchone()[1]) + inventories.append( + schema(connection.execute("SELECT sql FROM sqlite_schema WHERE name='example'").fetchone()[0]) + ) + assert columns[0] != columns[1] + assert inventories[0] != inventories[1] From 52bebcf2895183fa94cd85bfe720220cd6d7c7ac Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Sun, 25 Oct 2026 15:34:40 +0800 Subject: [PATCH 10/11] fix(ci): validate upgrade profiles and reap acceptance processes --- .github/workflows/master.yml | 8 +- docs/en/development/releasing.md | 2 + docs/zh/development/releasing.md | 2 + scripts/upgrade_process.py | 139 ++++++++++++ scripts/upgrade_release_gate.py | 58 ++++- tests/release_upgrades/README.md | 17 +- tests/release_upgrades/test_full_server.py | 5 + tests/test_upgrade_release_gate.py | 239 ++++++++++++++++++++- 8 files changed, 459 insertions(+), 11 deletions(-) create mode 100644 scripts/upgrade_process.py diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 3b7f91f04b..6a3a53ded0 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -34,10 +34,10 @@ jobs: persist-credentials: false - name: Set up the environment uses: ./.github/actions/setup-python-env - - name: Verify upgrade policy and gate regressions - run: | - make upgrade-policy-check - uv run pytest -q tests/test_upgrade_release_gate.py tests/test_upgrade_publication.py tests/test_upgrade_schema_equivalence.py + - name: Run upgrade gate regressions + run: uv run pytest -q tests/test_upgrade_release_gate.py tests/test_upgrade_publication.py tests/test_upgrade_schema_equivalence.py + - name: Verify upgrade policy + run: make upgrade-policy-check - name: Build the target artifact run: uv build --out-dir dist - name: Start the isolated upgrade OceanBase environment diff --git a/docs/en/development/releasing.md b/docs/en/development/releasing.md index 1f76f902e9..3958322c62 100644 --- a/docs/en/development/releasing.md +++ b/docs/en/development/releasing.md @@ -155,6 +155,8 @@ make upgrade-release-test uv run python scripts/run_upgrade_release_gate.py --release --release-notes /path/to/release-notes.md ``` +Full installed-release acceptance requires Linux process ownership and cleanup. Run it on Linux or in the GitHub Actions upgrade job; other hosts can run the policy and publication regression checks. + The publication input directory must contain exactly one wheel and one matching `.tar.gz` source distribution; leftover distributions and unexpected files fail validation (`uv`'s `.gitignore` is not an artifact). The runner checks source metadata and builds a wheel from the source archive, comparing all member contents except the derived diff --git a/docs/zh/development/releasing.md b/docs/zh/development/releasing.md index 4f65ff503b..513bf194e9 100644 --- a/docs/zh/development/releasing.md +++ b/docs/zh/development/releasing.md @@ -145,6 +145,8 @@ make upgrade-release-test uv run python scripts/run_upgrade_release_gate.py --release --release-notes /path/to/release-notes.md ``` +完整安装包升级验收需要 Linux 的进程归属和清理机制。请在 Linux 或 GitHub Actions 升级任务中执行;其他平台可运行策略检查和发布逻辑回归测试。 + 发布输入目录必须只有一个 wheel 和一个配套的 `.tar.gz` 源码包;残留发行包和其他文件都会使检查失败, `uv` 生成的 `.gitignore` 不作为发行产物。门禁检查源码包元数据,并从该源码包实际重建 wheel,逐项比较归档中的 文件内容,仅排除派生的 `RECORD` 哈希清单。因此,同版本但内容过期的源码包也无法绕过检查。归档时间戳和压缩 diff --git a/scripts/upgrade_process.py b/scripts/upgrade_process.py new file mode 100644 index 0000000000..949f97b3e4 --- /dev/null +++ b/scripts/upgrade_process.py @@ -0,0 +1,139 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Own one Linux upgrade command and reap its descendants, including new sessions.""" + +import argparse +import ctypes +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + + +def own_descendants(expected_parent: int) -> None: + if sys.platform != "linux": + raise RuntimeError("Installed-release acceptance requires Linux") # noqa: TRY003 + # A separate, single-threaded supervisor cannot adopt unrelated gate/test + # children. setsid() does not escape Linux subreaper ancestry. + libc = ctypes.CDLL(None, use_errno=True) + if libc.prctl(36, 1, 0, 0, 0) != 0: # PR_SET_CHILD_SUBREAPER + error = ctypes.get_errno() + raise OSError(error, os.strerror(error)) + if libc.prctl(1, signal.SIGTERM, 0, 0, 0) != 0: # PR_SET_PDEATHSIG + error = ctypes.get_errno() + raise OSError(error, os.strerror(error)) + if os.getppid() != expected_parent: + raise RuntimeError("The gate exited before process ownership was established") # noqa: TRY003 + signal.signal(signal.SIGCHLD, signal.SIG_DFL) + + +def children() -> list[int]: + return [int(value) for value in Path(f"/proc/self/task/{os.getpid()}/children").read_text().split()] + + +def reap_finished(exclude: int | None = None) -> set[int]: + reaped = set() + for pid in children(): + if pid != exclude and os.waitpid(pid, os.WNOHANG)[0]: + reaped.add(pid) + return reaped + + +def wait_command(process: subprocess.Popen[bytes], timeout: float) -> int: + deadline = time.monotonic() + timeout + while (result := process.poll()) is None: + # A live command may wait for a double-forked daemon to disappear. + # Reap already-exited adoptees without disturbing any live descendants. + reap_finished(exclude=process.pid) + if time.monotonic() >= deadline: + raise subprocess.TimeoutExpired(process.args, timeout) + time.sleep(0.02) + return result + + +def reap_descendants(shutdown_timeout: float) -> None: + deadline = time.monotonic() + shutdown_timeout + notified = {} + while True: + for pid in reap_finished(): + notified.pop(pid, None) + owned = children() + if not owned: + return + requested = signal.SIGTERM if time.monotonic() < deadline else signal.SIGKILL + for pid in owned: + if notified.get(pid) != requested: + # These are our direct children, never a global PID scan. No + # wait/reap happens between ownership inspection and signalling, + # so even an exited child's PID cannot be reused here. + os.kill(pid, requested) + notified[pid] = requested + # Killing a parent can adopt another detached generation. Keep owning + # and reaping it before returning, rather than leaving it to init. As + # with Popen.wait(), SIGKILL still waits for kernel-level exit/reaping; + # an uninterruptible kernel wait must not admit the next case early. + time.sleep(0.02) + + +def supervise(command: list[str], timeout: float, shutdown_timeout: float, expected_parent: int) -> int: + def interrupted(_signal, _frame): + raise KeyboardInterrupt + + signal.signal(signal.SIGTERM, interrupted) + own_descendants(expected_parent) + process = None + try: + process = subprocess.Popen(command) # noqa: S603 -- fixed gate command, no shell + try: + result = wait_command(process, timeout) + except subprocess.TimeoutExpired: + print(f"Upgrade command exceeded {timeout:g} seconds; closing owned processes", file=sys.stderr) + result = 124 + except KeyboardInterrupt: + result = 130 + finally: + # A repeated cancellation must not interrupt ownership cleanup. + signal.signal(signal.SIGINT, signal.SIG_IGN) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + if process is not None and process.poll() is None: + # Let pytest unwind fixtures and let the Server close SQL sessions + # and seekDB normally before forcing termination. + process.send_signal(signal.SIGINT) + try: + wait_command(process, shutdown_timeout) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + reap_descendants(shutdown_timeout) + return result if result >= 0 else 128 - result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--parent-pid", type=int, required=True) + parser.add_argument("--timeout", type=float, required=True) + parser.add_argument("--shutdown-timeout", type=float, default=30) + parser.add_argument("command", nargs=argparse.REMAINDER) + args = parser.parse_args() + command = args.command[1:] if args.command[:1] == ["--"] else args.command + if not command or args.timeout <= 0 or args.shutdown_timeout <= 0: + parser.error("A command and positive time limits are required") + return supervise(command, args.timeout, args.shutdown_timeout, args.parent_pid) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/upgrade_release_gate.py b/scripts/upgrade_release_gate.py index b2255ad72e..7ac4113358 100644 --- a/scripts/upgrade_release_gate.py +++ b/scripts/upgrade_release_gate.py @@ -21,6 +21,7 @@ import json import os import re +import signal import subprocess import sys import tempfile @@ -205,7 +206,7 @@ def validate_junit(report: Path) -> None: require(names >= PHASES, f"Missing full Server acceptance phases: {sorted(PHASES - names)}") -def validate_backend_evidence(path: Path, backend: str) -> dict[str, Any]: +def validate_backend_evidence(path: Path, backend: str, configuration: str) -> dict[str, Any]: evidence = json.loads(path.read_text(encoding="utf-8")) require(evidence.get("backend") == backend, "Backend evidence does not match the declared case") for key in ("engine_version", "driver_version", "source_schema", "target_schema", "fresh_schema"): @@ -216,6 +217,7 @@ def validate_backend_evidence(path: Path, backend: str) -> dict[str, Any]: isinstance(evidence.get("proxy_version"), str) and evidence["proxy_version"], "Record proxy version or direct", ) + require(evidence.get("configuration") == configuration, "Configuration evidence differs from the declared case") return evidence @@ -227,6 +229,51 @@ def run(command: list[str], *, environment: dict[str, str], cwd: Path, log: Path require(result.returncode == 0, f"Command failed; inspect {log.name}") +def run_owned( + command: list[str], + *, + environment: dict[str, str], + cwd: Path, + log: Path, + timeout: float = 1800, + shutdown_timeout: float = 30, +) -> None: + require(sys.platform == "linux", "Installed-release acceptance requires Linux process ownership and cleanup") + supervised = [ + sys.executable, + str(Path(__file__).with_name("upgrade_process.py")), + "--parent-pid", + str(os.getpid()), + "--timeout", + str(timeout), + "--shutdown-timeout", + str(shutdown_timeout), + "--", + *command, + ] + # The supervisor enforces the deadline and reaps every owned descendant, + # including detached native engines, before workspace cleanup can run. + with ( + log.open("w", encoding="utf-8") as output, + subprocess.Popen( # noqa: S603 -- fixed gate supervisor and reviewed command, no shell + supervised, env=environment, cwd=cwd, stdout=output, stderr=subprocess.STDOUT, start_new_session=True + ) as process, + ): + try: + returncode = process.wait() + except BaseException: + # Repeated interrupts must not release the workspace before the + # owner has finished closing descendants. + previous = signal.signal(signal.SIGINT, signal.SIG_IGN) + try: + process.terminate() + process.wait() + finally: + signal.signal(signal.SIGINT, previous) + raise + require(returncode == 0, f"Command failed; inspect {log.name}") + + def installed(python: Path, environment: dict[str, str], cwd: Path) -> dict[str, str]: result = subprocess.run( # noqa: S603 -- isolated environment interpreter [ @@ -256,6 +303,7 @@ def wheel_version(wheel: Path) -> str: def run_case(root: Path, wheel: Path, case: dict[str, Any], output: Path) -> dict[str, Any]: + require(sys.platform == "linux", "Installed-release acceptance requires Linux process ownership and cleanup") output.mkdir(parents=True, exist_ok=False) # Never inherit a developer's live deployment or pytest selection overrides. environment = { @@ -274,14 +322,14 @@ def run_case(root: Path, wheel: Path, case: dict[str, Any], output: Path) -> dic } for role, package in packages.items(): venv = directory / role - run( + run_owned( ["uv", "venv", "--python", sys.executable, str(venv)], environment=environment, cwd=directory, log=output / f"{role}-venv.log", ) python = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python") - run( + run_owned( ["uv", "pip", "install", "--python", str(python), package], environment=environment, cwd=directory, @@ -304,7 +352,7 @@ def run_case(root: Path, wheel: Path, case: dict[str, Any], output: Path) -> dic "PC_UPGRADE_EVIDENCE": str(output / "backend.json"), }) report = output / "junit.xml" - run( + run_owned( [ sys.executable, "-m", @@ -327,7 +375,7 @@ def run_case(root: Path, wheel: Path, case: dict[str, Any], output: Path) -> dic log=output / "acceptance.log", ) validate_junit(report) - backend = validate_backend_evidence(output / "backend.json", case["backend"]) + backend = validate_backend_evidence(output / "backend.json", case["backend"], case["configuration"]) return {"case": case, "installed": metadata, "database": backend, "status": "passed"} diff --git a/tests/release_upgrades/README.md b/tests/release_upgrades/README.md index 39289f177a..184e77cb7a 100644 --- a/tests/release_upgrades/README.md +++ b/tests/release_upgrades/README.md @@ -19,7 +19,11 @@ source wheels, invalid artifact metadata, catalog outages, and a target at or be Evidence records the catalog digest, exact source artifact, target artifact digest, and expanded matrix. The resolver requires universal `py3-none-any` wheels; a distribution format change must extend it explicitly. -All three profiles use `test_full_server.py`. It starts the installed 1.3.0 Server, writes data through HTTP, +All three profiles use `test_full_server.py`, which accepts only `full-server-no-vector`. Any other configuration +fails before opening a database; adding a policy entry alone does not implement a new profile. Backend evidence +records the executed configuration and the controller requires it to match the declaration. + +The suite starts the installed 1.3.0 Server, writes data through HTTP, stops it, seeds a pending processing intent using the installed SDK, and runs the target wheel's public `server db-migrate status/plan/apply/verify` commands. Verification must report `state=ready` and `server_ready=true`. The target Server must then start, preserve data and permissions, accept new writes, and support retrieval. @@ -125,3 +129,14 @@ Acceptance uses a private copy of those files. Only a passing run creates `valid in its evidence directory. CI uploads that directory; local `make publish` calls the runner with `--release --publish` and uploads its exact returned paths, never a fresh glob of `dist/`. Failed acceptance produces no publication set. Managed persistence hashes normalize CRLF/LF checkout line endings; artifact SHA-256s remain byte-exact. + +## Process ownership + +Full installed-release acceptance runs on Linux, including local invocations of the complete gate. +Other hosts can run policy, publication and schema regression checks, but cannot run full acceptance. +Each installation or acceptance command runs under an isolated Linux child subreaper. It retains ownership +of detached Server and embedded-engine descendants, allows graceful shutdown, then forces termination +and reaps remaining processes before workspace deletion or the next backend. Timeout, failure, success +with leaked children, and gate cancellation all use this cleanup. A parent-death signal starts cleanup +if the gate itself exits. An uninterruptible kernel wait blocks cleanup rather than admitting another case +while the previous processes still own storage. diff --git a/tests/release_upgrades/test_full_server.py b/tests/release_upgrades/test_full_server.py index 047e064c53..4c1f3417cf 100644 --- a/tests/release_upgrades/test_full_server.py +++ b/tests/release_upgrades/test_full_server.py @@ -284,7 +284,11 @@ def seed(client, *, candidate_prefix): def case(): # Missing gate inputs are errors, never skips or an implicit local database. assert os.environ["PC_UPGRADE_SOURCE_VERSION"] == "1.3.0", "Add a reviewed adapter for another source release" + configuration = os.environ["PC_UPGRADE_CONFIGURATION"] + if configuration != "full-server-no-vector": + pytest.fail(f"Unimplemented acceptance configuration: {configuration}") return { + "configuration": configuration, "source": Path(os.environ["PC_UPGRADE_SOURCE_PYTHON"]), "target": Path(os.environ["PC_UPGRADE_TARGET_PYTHON"]), "backend": os.environ["PC_UPGRADE_BACKEND"], @@ -508,6 +512,7 @@ def test_fresh_upgrade_equivalence(case, migrated): ) evidence = { "backend": case["backend"], + "configuration": case["configuration"], "engine_version": inventory["engine_version"], "driver_version": inventory["driver_version"], "source_schema": digest(migrated["historical"]["schema"]), diff --git a/tests/test_upgrade_release_gate.py b/tests/test_upgrade_release_gate.py index 49fc331039..1b8d9eb35d 100644 --- a/tests/test_upgrade_release_gate.py +++ b/tests/test_upgrade_release_gate.py @@ -16,6 +16,12 @@ import json import os +import shutil +import signal +import subprocess +import sys +import tempfile +import time import zipfile from pathlib import Path from xml.etree import ElementTree @@ -219,7 +225,7 @@ def test_missing_backend_evidence_fails_even_with_a_passing_report(tmp_path): report = tmp_path / "backend.json" report.write_text(json.dumps({"backend": "oceanbase", "engine_version": "4.6.3"})) with pytest.raises(gate.GateError, match="driver_version"): - gate.validate_backend_evidence(report, "oceanbase") + gate.validate_backend_evidence(report, "oceanbase", "full-server-no-vector") def test_old_evidence_directory_cannot_be_reused(release): @@ -489,3 +495,234 @@ def test_artifact_hashes_do_not_normalize_bytes(tmp_path): before = gate.sha256(artifact) artifact.write_bytes(b"payload\r\n") assert gate.sha256(artifact) != before + + +_PROCESS_FIXTURE = """ +import os, signal, subprocess, sys, time +from pathlib import Path + +role, mode, control, evidence = sys.argv[1:] +control, evidence = Path(control), Path(evidence) +child = None +if mode == "forced-timeout": + signal.signal(signal.SIGINT, signal.SIG_IGN) + signal.signal(signal.SIGTERM, signal.SIG_IGN) +else: + def stop(_signal, _frame): + raise SystemExit(0) + signal.signal(signal.SIGTERM, stop) +try: + ready = evidence / (role + ".ready") + ready.write_text(str(os.getpid())) + ready.replace(evidence / (role + ".pid")) + if role != "engine": + next_role = "server" if role == "root" else "engine" + child = subprocess.Popen( + [sys.executable, __file__, next_role, mode, str(control), str(evidence)], + start_new_session=True, + ) + if role == "root" and mode in {"success", "failure"}: + while not (evidence / "engine.pid").exists(): + time.sleep(0.02) + # Deliberately leave both detached generations for the command owner. + os._exit(0 if mode == "success" else 7) + while control.exists(): + time.sleep(0.02) +finally: + if child is not None: + child.terminate() + try: + child.wait(timeout=2) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + (evidence / (role + ".closed")).write_text("closed") +""" + + +@pytest.mark.skipif(sys.platform != "linux", reason="The upgrade supervisor uses Linux subreaper ownership") +@pytest.mark.parametrize("mode", ["success", "failure", "timeout", "forced-timeout"]) +def test_upgrade_command_reaps_detached_descendants_before_workspace_cleanup(tmp_path, mode): + fixture = tmp_path / "process_fixture.py" + fixture.write_text(_PROCESS_FIXTURE) + control = tmp_path / "keep-running" + control.touch() + report = tmp_path / "evidence" + report.mkdir() + # This child belongs to the test, not the isolated command supervisor. + sentinel = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + try: + with tempfile.TemporaryDirectory(dir=tmp_path) as directory: + work = Path(directory) + arguments = [sys.executable, str(fixture), "root", mode, str(control), str(report)] + options = { + "environment": dict(os.environ), + "cwd": work, + "log": report / "command.log", + "timeout": 5 if mode.endswith("timeout") else 20, + "shutdown_timeout": 0.5, + } + if mode == "success": + gate.run_owned(arguments, **options) + else: + with pytest.raises(gate.GateError, match="Command failed"): + gate.run_owned(arguments, **options) + assert sentinel.poll() is None + for role in ("root", "server", "engine"): + pid = int((report / f"{role}.pid").read_text()) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + if mode != "forced-timeout": + assert (report / "server.closed").is_file() + assert (report / "engine.closed").is_file() + if mode.endswith("timeout"): + assert "exceeded 5 seconds" in (report / "command.log").read_text() + assert not work.exists() + finally: + # Also let controlled fixture processes exit if the regression fails. + # Never signal a stale PID obtained from a fixture file. + control.unlink(missing_ok=True) + sentinel.terminate() + sentinel.wait(timeout=5) + + +@pytest.mark.skipif(sys.platform != "linux", reason="The upgrade supervisor uses Linux subreaper ownership") +@pytest.mark.parametrize("cancellation", ["parent-term", "repeat-interrupt"]) +def test_upgrade_command_owner_finishes_cleanup_after_gate_cancellation(tmp_path, cancellation): + fixture = tmp_path / "process_fixture.py" + fixture.write_text(_PROCESS_FIXTURE) + control = tmp_path / "keep-running" + control.touch() + report = tmp_path / "evidence" + report.mkdir() + owner_script = tmp_path / "owner.py" + owner_script.write_text( + "import os,sys\nfrom pathlib import Path\n" + f"sys.path[:0] = {[str(Path(gate.__file__).parent), str(Path(matrix.__file__).parent)]!r}\n" + "import upgrade_release_gate as gate\n" + f"report = Path({str(report)!r})\n" + "from tempfile import TemporaryDirectory\n" + "with TemporaryDirectory() as directory:\n" + " work = Path(directory)\n" + " (report / 'workspace').write_text(str(work))\n" + f" gate.run_owned({[sys.executable, str(fixture), 'root', 'forced-timeout', str(control), str(report)]!r}, " + "environment=dict(os.environ), cwd=work, log=report / 'command.log', timeout=60, shutdown_timeout=0.5)\n" + ) + sentinel = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + with (report / "owner.log").open("w") as log: + owner = subprocess.Popen([sys.executable, str(owner_script)], stdout=log, stderr=subprocess.STDOUT) + pids = [] + try: + deadline = time.monotonic() + 15 + while not (report / "engine.pid").exists(): + assert owner.poll() is None, (report / "owner.log").read_text() + assert time.monotonic() < deadline, "Controlled processes did not become ready" + time.sleep(0.02) + pids = [int((report / f"{role}.pid").read_text()) for role in ("root", "server", "engine")] + work = Path((report / "workspace").read_text()) + owner.send_signal(signal.SIGTERM if cancellation == "parent-term" else signal.SIGINT) + if cancellation == "repeat-interrupt": + time.sleep(0.1) + assert owner.poll() is None, "Cancellation did not wait for owned processes" + owner.send_signal(signal.SIGINT) + assert owner.wait(timeout=10) != 0 + if cancellation == "parent-term": + # The dead gate cannot await its owner; PDEATHSIG initiates cleanup. + deadline = time.monotonic() + 10 + while any(_process_exists(pid) for pid in pids): + assert time.monotonic() < deadline, "Detached descendants survived gate termination" + time.sleep(0.02) + else: + # The second interrupt must not delete the workspace early. + assert not any(_process_exists(pid) for pid in pids) + assert not work.exists() + assert sentinel.poll() is None + finally: + control.unlink(missing_ok=True) + if owner.poll() is None: + owner.terminate() + owner.wait(timeout=5) + sentinel.terminate() + sentinel.wait(timeout=5) + if cancellation == "parent-term" and pids and not any(_process_exists(pid) for pid in pids): + shutil.rmtree(Path((report / "workspace").read_text()), ignore_errors=True) + + +def _process_exists(pid): + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + return True + + +@pytest.mark.parametrize("configuration", ["full-server-vector", "full-server-code", "full-server-vector-code", "typo"]) +def test_unimplemented_configuration_fails_before_opening_database(tmp_path, configuration): + work = tmp_path / "must-not-be-created" + suite = gate.ROOT / "tests/release_upgrades/test_full_server.py" + environment = dict(os.environ) + environment.update( + PYTEST_DISABLE_PLUGIN_AUTOLOAD="1", + PC_UPGRADE_CONFIGURATION=configuration, + PC_UPGRADE_SOURCE_VERSION="1.3.0", + PC_UPGRADE_SOURCE_PYTHON=str(tmp_path / "nonexistent-source"), + PC_UPGRADE_TARGET_PYTHON=str(tmp_path / "nonexistent-target"), + PC_UPGRADE_BACKEND="sqlite", + PC_UPGRADE_WORK=str(work), + PC_UPGRADE_REPORT_DIR=str(tmp_path / "report"), + ) + environment.pop("PYTEST_ADDOPTS", None) + result = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-c", + os.devnull, + "--confcutdir", + str(suite.parent), + "-p", + "no:cacheprovider", + "-k", + "test_source_release_database", + "-q", + str(suite), + ], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode != 0 + assert f"Unimplemented acceptance configuration: {configuration}" in result.stdout + assert not work.exists() + + +@pytest.mark.parametrize("actual", [None, "full-server-vector", "full-server-no-vector"]) +def test_backend_evidence_must_match_executed_configuration(tmp_path, actual): + evidence = { + "backend": "sqlite", + "engine_version": "3", + "driver_version": "1", + "source_schema": "a", + "target_schema": "b", + "fresh_schema": "b", + } + if actual is not None: + evidence["configuration"] = actual + path = tmp_path / "backend.json" + path.write_text(json.dumps(evidence)) + if actual == "full-server-no-vector": + assert gate.validate_backend_evidence(path, "sqlite", actual) == evidence + else: + with pytest.raises(gate.GateError, match="Configuration evidence"): + gate.validate_backend_evidence(path, "sqlite", "full-server-no-vector") + + +@pytest.mark.skipif(sys.platform == "linux", reason="Linux supports the required process ownership") +def test_full_acceptance_rejects_unsupported_host_before_installing(tmp_path): + with pytest.raises(gate.GateError, match="requires Linux"): + gate.run_case(tmp_path, tmp_path / "wheel", {}, tmp_path / "report") + assert not (tmp_path / "report").exists() From 91eb0fd7aae26745a0fe4ce8651a03edaf1b1e01 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Mon, 26 Oct 2026 18:02:59 +0800 Subject: [PATCH 11/11] fix(ci): compare native trigger timing in upgrade acceptance --- scripts/upgrade_database_probe.py | 4 ++- tests/release_upgrades/README.md | 18 ++++++++++- tests/test_upgrade_schema_equivalence.py | 41 +++++++++++++++++++++++- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/scripts/upgrade_database_probe.py b/scripts/upgrade_database_probe.py index 6e85bb0031..4fa141e17e 100644 --- a/scripts/upgrade_database_probe.py +++ b/scripts/upgrade_database_probe.py @@ -163,7 +163,9 @@ async def inspect(backend: str, database: Path, *, add_revision: bool, canonical trigger_rows = [ dict(zip((column[0] for column in cursor.description), row, strict=True)) for row in cursor.fetchall() ] - triggers = {row["Trigger"]: [row["Event"], row["Table"], row["Statement"]] for row in trigger_rows} + triggers = { + row["Trigger"]: [row["Timing"], row["Event"], row["Table"], row["Statement"]] for row in trigger_rows + } logical = None if canonical: from powercontext.builtin.persistence.migrations.mysql_schema import _parse_table diff --git a/tests/release_upgrades/README.md b/tests/release_upgrades/README.md index 4a1edacc5a..d06e17e452 100644 --- a/tests/release_upgrades/README.md +++ b/tests/release_upgrades/README.md @@ -158,7 +158,8 @@ The separate SDK fixture tests the old pending intent's format and acknowledgmen execution and evidence preservation, not real-model quality. Vector storage uses its separately registered profiles; passing a no-vector case cannot establish vector acceptance. SQLite inventories include indexes, views and triggers; MySQL-family table DDL includes indexes and constraints, -and views are collected separately. MySQL comparison parses columns, indexes (including parser properties), constraints, and CHECK expressions, ignoring only physical formatting and backend-generated constraint names. Views and triggers are collected independently. SQLite normalizes whitespace and identifier quoting. Structural differences fail rather than being silently excluded. +and views are collected separately. MySQL comparison parses columns, indexes (including parser properties), constraints, and CHECK expressions, ignoring only physical formatting and backend-generated constraint names. Views and triggers are collected independently; native trigger comparison includes timing (`BEFORE`/`AFTER`), +event, table and statement. SQLite preserves quoted tokens and normalizes only unquoted formatting whitespace. Structural differences fail rather than being silently excluded. Regenerate installation support regions with `uv run python scripts/upgrade_release_gate.py --write-docs` and run `make upgrade-policy-check`. When persistence @@ -224,3 +225,18 @@ as formatting changes. Unknown configurations and missing or mismatched executio Full installed-release acceptance requires Linux process ownership and descendant cleanup. Other hosts can run the policy and comparison regressions, but the full runner rejects them before creating workspaces or installing packages. Passing local regressions is not evidence of a successful native-backend upgrade. + + +## Acceptance boundaries + +A passing run establishes the selected cases for that exact target build, not every historical production database. +The source fixture is newly initialized with the official 1.3.0 package and representative data; it does not recreate +an older-version-to-1.3.0 lineage or an existing user's database. Each future target build must rerun acceptance. +The Linux matrix does not establish macOS embedded-engine, desktop-daemon or in-place tool-installation behavior. +Source and target dependency resolution does not freeze the dependencies of a historical installation, so recorded +engine/driver versions must be considered when interpreting compatibility evidence. + +The positive path exercises the public `db-migrate` commands, not the historical `atomic-memory-migrate` procedure. +SQLite tests automatic backup restoration; seekDB and OceanBase explicitly skip backups in this suite and do not +establish backup/rollback acceptance. The two manual embedded seekDB Code cases require their own successful runs; +the ten automatic results cannot establish those configurations. diff --git a/tests/test_upgrade_schema_equivalence.py b/tests/test_upgrade_schema_equivalence.py index 48d31cce4c..863af30316 100644 --- a/tests/test_upgrade_schema_equivalence.py +++ b/tests/test_upgrade_schema_equivalence.py @@ -14,11 +14,15 @@ """Prevent schema equivalence from erasing differences in SQL literals.""" +import asyncio import json import sqlite3 +from types import SimpleNamespace +from unittest.mock import MagicMock import pytest +from scripts import upgrade_database_probe from tests.release_upgrades.test_full_server import canonical_schema @@ -90,8 +94,43 @@ def test_native_logical_schema_preserves_object_differences(kind): left = { "logical_tables": {"example": {"default": "a b"}}, "views": {"example_view": "SELECT 'a b'"}, - "triggers": {"example_trigger": ["INSERT", "example", "SET NEW.value = 'a b'"]}, + "triggers": {"example_trigger": ["BEFORE", "INSERT", "example", "SET NEW.value = 'a b'"]}, } right = json.loads(json.dumps(left).replace("a b", "a b")) changed = {**left, kind: right[kind]} assert canonical_schema(left) != canonical_schema(changed) + + +def test_native_probe_preserves_trigger_timing(monkeypatch, tmp_path): + """Synthetic driver rows exercise the probe and comparator, not a live backend.""" + monkeypatch.setenv("PC_UPGRADE_OCEANBASE_URL", "mysql://fixture@localhost/fixture") + monkeypatch.delenv("POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_MODEL", raising=False) + monkeypatch.setattr(upgrade_database_probe, "version", lambda _name: "test-driver") + snapshots = [] + for timing in ("BEFORE", "AFTER"): + cursor = MagicMock() + cursor.fetchone.return_value = ("test-engine", "mysql") + cursor.fetchall.return_value = [] + + def execute(query, *, timing=timing, cursor=cursor): + if query == "SHOW TRIGGERS": + fields = ("Trigger", "Event", "Table", "Statement", "Timing") + cursor.description = [(field,) for field in fields] + cursor.fetchall.return_value = [("example_trigger", "INSERT", "example", "SET @seen = 1", timing)] + + cursor.execute.side_effect = execute + connection = MagicMock() + connection.cursor.return_value.__enter__.return_value = cursor + connect = MagicMock() + connect.return_value.__enter__.return_value = connection + monkeypatch.setattr( + upgrade_database_probe, "import_module", lambda _name, connect=connect: SimpleNamespace(connect=connect) + ) + snapshot = asyncio.run( + upgrade_database_probe.inspect("oceanbase", tmp_path, add_revision=False, canonical=True) + ) + snapshots.append(json.loads(json.dumps(snapshot))) + + assert snapshots[0]["trigger_rows"][0]["Timing"] == "BEFORE" + assert snapshots[1]["trigger_rows"][0]["Timing"] == "AFTER" + assert canonical_schema(snapshots[0]) != canonical_schema(snapshots[1])