diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b7dcf34a3..a8315982e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,20 +3,106 @@ name: Release on: release: types: [published] + workflow_dispatch: + inputs: + release_tag: + description: Existing published Release tag to build and publish + required: true + type: string + skip_upgrade_acceptance: + description: Publish for new databases only, without database upgrade acceptance + type: boolean + default: false permissions: contents: read concurrency: - group: release-${{ github.event.release.tag_name || github.ref }} + group: release-${{ inputs.release_tag || github.event.release.tag_name || github.ref }} cancel-in-progress: false jobs: + resolve-release: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + tag: ${{ steps.release.outputs.tag }} + source_sha: ${{ steps.release.outputs.source_sha }} + steps: + - name: Resolve the published Release and pin its source + id: release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag || github.event.release.tag_name }} + SKIP_UPGRADE_ACCEPTANCE: ${{ inputs.skip_upgrade_acceptance || false }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + python - <<'PY' + import json + import os + import re + import urllib.parse + import urllib.request + from pathlib import Path + + tag = os.environ["RELEASE_TAG"] + if not re.fullmatch(r"(?:powercontext-v|v)?\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?", tag): + raise SystemExit("Invalid PowerContext Release tag") + manual = os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch" + skip = os.environ["SKIP_UPGRADE_ACCEPTANCE"] == "true" + if manual and os.environ["GITHUB_REF"] != "refs/heads/" + os.environ["DEFAULT_BRANCH"]: + raise SystemExit("Run manual publication from the default branch; select the target with release_tag") + if skip and not manual: + raise SystemExit("Skipping upgrade acceptance requires explicit manual dispatch") + + def get(path): + request = urllib.request.Request( + f"https://api.github.com/repos/{os.environ['GITHUB_REPOSITORY']}/{path}", + headers={"Authorization": f"Bearer {os.environ['GH_TOKEN']}", + "Accept": "application/vnd.github+json"}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + quoted_tag = urllib.parse.quote(tag, safe="") + release = get(f"releases/tags/{quoted_tag}") + if release["tag_name"] != tag or release["draft"] or not release["published_at"]: + raise SystemExit("Select an existing published GitHub Release") + notice = ( + "Upgrade acceptance was skipped for this release. " + "Use only new databases; upgrades of existing databases are not supported." + ) + if skip and notice not in (release["body"] or ""): + raise SystemExit("Add this exact notice to the GitHub Release before skipping upgrades: " + notice) + commit = get(f"commits/{urllib.parse.quote('refs/tags/' + tag, safe='')}")["sha"] + if not re.fullmatch(r"[0-9a-f]{40}", commit): + raise SystemExit("Release tag did not resolve to a commit") + if not manual and commit != os.environ["GITHUB_SHA"]: + raise SystemExit("Release tag changed since the published event") + event = Path(os.environ["RUNNER_TEMP"]) / "release-metadata" / "event.json" + event.parent.mkdir() + event.write_text(json.dumps({"release": release}, ensure_ascii=False), encoding="utf-8") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"tag={tag}\nsource_sha={commit}\n") + PY + - name: Preserve the Release notes used for validation + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: release-metadata + path: ${{ runner.temp }}/release-metadata/event.json + if-no-files-found: error + overwrite: true + retention-days: 30 + release-build: + needs: resolve-release uses: ./.github/workflows/upgrade-release.yml with: release: true selection: automatic + source_ref: ${{ needs.resolve-release.outputs.source_sha }} + release_tag: ${{ needs.resolve-release.outputs.tag }} + skip_upgrade_acceptance: ${{ github.event_name == 'workflow_dispatch' && inputs.skip_upgrade_acceptance }} pypi-publish: runs-on: ubuntu-latest @@ -37,7 +123,7 @@ jobs: release-assets: runs-on: ubuntu-latest timeout-minutes: 15 - needs: release-build + needs: [resolve-release, release-build] permissions: contents: write steps: @@ -50,21 +136,21 @@ jobs: - name: Upload GitHub Release assets uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: - tag_name: ${{ github.event.release.tag_name }} + tag_name: ${{ needs.resolve-release.outputs.tag }} files: dist/* fail_on_unmatched_files: true release-verify: - needs: [pypi-publish, release-assets] + needs: [resolve-release, pypi-publish, release-assets] permissions: contents: read uses: ./.github/workflows/release-verify.yml with: - release_tag: ${{ github.event.release.tag_name }} + release_tag: ${{ needs.resolve-release.outputs.tag }} release_package: powercontext deploy-website: - needs: pypi-publish + needs: [resolve-release, pypi-publish] permissions: contents: read pages: write @@ -78,6 +164,8 @@ jobs: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + ref: ${{ needs.resolve-release.outputs.source_sha }} + fetch-depth: 0 persist-credentials: false - name: Set up the environment diff --git a/.github/workflows/upgrade-release.yml b/.github/workflows/upgrade-release.yml index f04b5fdfe0..728c09c306 100644 --- a/.github/workflows/upgrade-release.yml +++ b/.github/workflows/upgrade-release.yml @@ -11,10 +11,25 @@ on: description: Enforce release metadata and expose accepted publication artifacts type: boolean default: false + source_ref: + description: Pinned release commit; empty uses the calling workflow checkout + type: string + default: '' + release_tag: + description: Published Release tag selected by the release workflow + type: string + default: '' + skip_upgrade_acceptance: + description: Explicit manual publication for new databases without upgrade acceptance + type: boolean + default: false permissions: contents: read +env: + PC_UPGRADE_SOURCE_SHA: ${{ inputs.source_ref || github.sha }} + jobs: prepare: name: Prepare upgrade artifacts and matrix @@ -26,16 +41,34 @@ jobs: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + ref: ${{ inputs.source_ref }} fetch-depth: 0 persist-credentials: false + - name: Require an explicit manual release for the upgrade bypass + env: + SKIP_UPGRADE_ACCEPTANCE: ${{ inputs.skip_upgrade_acceptance }} + RELEASE: ${{ inputs.release }} + run: | + if [ "$SKIP_UPGRADE_ACCEPTANCE" = true ]; then + test "$GITHUB_EVENT_NAME" = workflow_dispatch + test "$RELEASE" = true + fi + - name: Set up the environment uses: ./.github/actions/setup-python-env + - name: Download the frozen Release metadata + if: inputs.release + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: release-metadata + path: ${{ runner.temp }}/release-metadata/ + - name: Verify package version from Release tag if: inputs.release env: - RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TAG: ${{ inputs.release_tag }} run: | python - <<'PY' import os @@ -77,7 +110,7 @@ jobs: - name: Verify synchronized release references and release notes if: inputs.release env: - VERSION: ${{ github.event.release.tag_name }} + VERSION: ${{ inputs.release_tag }} run: make release-check - name: Run upgrade gate regressions @@ -92,6 +125,7 @@ jobs: uvx --from twine twine check dist/* - name: Freeze distributions and resolve the source matrix once + if: ${{ !inputs.skip_upgrade_acceptance }} id: prepare env: RELEASE: ${{ inputs.release }} @@ -99,12 +133,13 @@ jobs: run: | args=() if [ "$RELEASE" = true ]; then - args+=(--release --release-event "$GITHUB_EVENT_PATH") + args+=(--release --release-event "$RUNNER_TEMP/release-metadata/event.json") fi uv run python scripts/parallel_upgrade_release_gate.py prepare \ --output "$RUNNER_TEMP/upgrade-prepared" --selection "$SELECTION" "${args[@]}" - name: Share the exact prepared inputs + if: ${{ !inputs.skip_upgrade_acceptance }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: upgrade-prepared @@ -116,9 +151,99 @@ jobs: overwrite: true retention-days: 30 + - name: Validate distributions without claiming upgrade acceptance + if: inputs.skip_upgrade_acceptance + env: + PYTHONPATH: scripts + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + uv run python - <<'PY' + import json + import os + import subprocess + from pathlib import Path + from run_upgrade_release_gate import stage_distributions + from upgrade_release_gate import ROOT, load_policy, require, sha256, wheel_version + + policy = load_policy(ROOT) + require(policy["mode"] == "upgrade-required", "The bypass must not declare supported upgrades") + output = Path(os.environ["RUNNER_TEMP"]) / "release-without-upgrades" + output.mkdir() + wheel, files = stage_distributions(Path("dist"), output / "validated-dist", release=True) + require(wheel_version(wheel) == policy["target_version"], "Wheel version differs from release policy") + evidence = { + "release_tag": os.environ["RELEASE_TAG"], + "source_sha": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "workflow_run_id": os.environ["GITHUB_RUN_ID"], + "actor": os.environ["GITHUB_ACTOR"], + "upgrade_acceptance": "skipped", + "supported_use": "new-databases-only", + "artifacts": {path.name: sha256(path) for path in files}, + } + (output / "publication.json").write_text(json.dumps(evidence, indent=2) + "\n") + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary: + summary.write("## Upgrade acceptance skipped\n\n") + summary.write("Published use is limited to new databases. Existing database upgrades are unsupported.\n") + summary.write(f"\nTag: `{evidence['release_tag']}`; source: `{evidence['source_sha']}`.\n") + PY + + - name: Verify a clean installation and new-database Server, MCP and Memory behavior + if: inputs.skip_upgrade_acceptance + run: | + verification="$RUNNER_TEMP/release-fresh-install" + uv venv "$verification" + wheels=("$RUNNER_TEMP"/release-without-upgrades/validated-dist/*.whl) + uv pip install --python "$verification/bin/python" "${wheels[0]}[cli,server]" + uv pip check --python "$verification/bin/python" + version="$("$verification/bin/python" -c 'from importlib.metadata import version; print(version("powercontext"))')" + "$verification/bin/python" scripts/ci_release_smoke.py --version "$version" + + - name: Bind successful fresh-install verification to the uploaded bytes + if: inputs.skip_upgrade_acceptance + env: + PYTHONPATH: scripts + run: | + uv run python - <<'PY' + import json + import os + from pathlib import Path + from upgrade_release_gate import require, sha256 + + output = Path(os.environ["RUNNER_TEMP"]) / "release-without-upgrades" + record = output / "publication.json" + evidence = json.loads(record.read_text()) + actual = {path.name: sha256(path) for path in (output / "validated-dist").iterdir()} + require(actual == evidence["artifacts"], "Distributions changed during fresh-install verification") + evidence["fresh_install_smoke"] = "passed" + record.write_text(json.dumps(evidence, indent=2) + "\n") + PY + + - name: Preserve the explicit upgrade bypass record + if: always() && inputs.skip_upgrade_acceptance + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: release-without-upgrades + path: | + ${{ runner.temp }}/release-without-upgrades/publication.json + ${{ runner.temp }}/release-without-upgrades/sdist-build.log + if-no-files-found: warn + overwrite: true + retention-days: 30 + + - name: Upload distributions after fresh-install verification only + if: success() && inputs.skip_upgrade_acceptance + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: release-dists + path: ${{ runner.temp }}/release-without-upgrades/validated-dist/* + if-no-files-found: error + overwrite: true + retention-days: 30 + cases: name: Upgrade ${{ matrix.source_version }} / ${{ matrix.backend }} / ${{ matrix.configuration }} needs: prepare + if: ${{ !inputs.skip_upgrade_acceptance }} runs-on: ubuntu-24.04 timeout-minutes: 60 strategy: @@ -131,6 +256,7 @@ jobs: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + ref: ${{ inputs.source_ref }} persist-credentials: false - name: Set up the environment uses: ./.github/actions/setup-python-env @@ -171,13 +297,14 @@ jobs: collect: name: Verify the selected upgrade matrix needs: [prepare, cases] - if: always() && needs.prepare.result == 'success' + if: always() && needs.prepare.result == 'success' && !inputs.skip_upgrade_acceptance runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + ref: ${{ inputs.source_ref }} persist-credentials: false - name: Set up the environment uses: ./.github/actions/setup-python-env diff --git a/docs/en/development/releasing.md b/docs/en/development/releasing.md index 0036cf640c..8434648022 100644 --- a/docs/en/development/releasing.md +++ b/docs/en/development/releasing.md @@ -132,7 +132,7 @@ installation pins are synchronized. `release/upgrade-support.json` declares the target version, source-version selection policy, backends, and configurations. `automatic_sources` keeps 1.3.0 as its fixed baseline; explicit `cases` are empty. The catalog declares twelve cases: one source × three backends × vectors enabled/disabled × Code enabled/disabled. -Publication requires the ten automatic cases to pass. The two embedded seekDB configurations with Code enabled +Normal publication requires the ten automatic cases to pass. The two embedded seekDB configurations with Code enabled are temporarily manual-only because of native DDL waits; they remain unverified and are excluded from supported upgrade declarations. Passing the automatic gate does not establish complete twelve-case acceptance. The target is always the actual wheel built by CI. Future target releases keep testing 1.3.0; @@ -193,11 +193,48 @@ seekDB runs embedded in a temporary runner directory. PR and publication jobs st 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 +By default, 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. +### Manual publication without upgrade acceptance + +The **Release** workflow has a `workflow_dispatch` input named `skip_upgrade_acceptance`, defaulting to `false`. +Enable it only to publish for new databases without claiming that existing databases can be upgraded. This does +not change `release/upgrade-support.json`, certify a migration path, or relax the default release and PR gates. +The target policy must remain `upgrade-required` when using this option. + +For an existing published Release such as `powercontext-v1.4.1`: + +1. Add this exact English notice to its GitHub Release body: + + ```text + Upgrade acceptance was skipped for this release. Use only new databases; upgrades of existing databases are not supported. + ``` + +2. In **Actions → Release → Run workflow**, select the repository's default branch (`master`), set + `release_tag` to `powercontext-v1.4.1`, and enable `skip_upgrade_acceptance`. The workflow definition must + already be merged into the default branch. Select the tag through the input, not the workflow branch selector. + + ```bash + gh workflow run release.yml --repo oceanbase/powercontext --ref master \ + -f release_tag=powercontext-v1.4.1 -f skip_upgrade_acceptance=true + ``` + +The workflow resolves the existing tag to a commit and builds that commit; it does not move the tag or package +the current default branch. It reads current Release notes rather than reusing the failed run's event payload. +Version checks, synchronized release references, policy consistency, package metadata checks, an sdist rebuild +comparison, clean installation, dependency checks, and the new-database Server/MCP/Memory smoke test remain +mandatory. Only then can the exact checked distributions reach PyPI and GitHub Release assets. Post-publication +verification and website deployment remain enabled, using the selected release target. + +The `release-without-upgrades` artifact records the tag, source commit, actor, run, distribution hashes, and +`upgrade_acceptance: skipped`; the run summary also states that existing-database upgrades are unsupported. +Leaving the option disabled runs the normal automatic upgrade matrix and support-declaration checks. + +### General release validation + Run the checks for the complete release changes, including generated contracts and documentation: ```bash diff --git a/docs/zh/development/releasing.md b/docs/zh/development/releasing.md index a1d6c3573c..a843c23a75 100644 --- a/docs/zh/development/releasing.md +++ b/docs/zh/development/releasing.md @@ -124,7 +124,7 @@ DSH README 中说明某项能力已包含在 `1.1.0` 的文字同样属于历史 `release/upgrade-support.json` 声明目标版本、来源版本选择策略、后端和配置。`automatic_sources` 继续将 1.3.0 作为固定基线,显式 `cases` 为空。共登记 12 个组合:一个来源 × 三种后端 × 向量开关 × Code 开关。 -发布要求十个自动验收组合全部通过。两个启用 Code 的嵌入式 seekDB 配置因原生 DDL 等待暂时仅手动运行, +常规发布要求十个自动验收组合全部通过。两个启用 Code 的嵌入式 seekDB 配置因原生 DDL 等待暂时仅手动运行, 尚未验收,不计入支持的升级声明;自动门禁通过不代表完整的十二项验收通过。 目标始终是本次 CI 构建的实际 wheel;后续目标发布版本继续测试 1.3.0,不自动加入中间发行版。 1.4.0 桥接实现和登记仍保留,但不纳入 CI 升级验收;门禁通过不能证明该路径已通过验收。来源 wheel 的公开 PyPI 地址和 SHA-256 与目标产物一起记录。 @@ -170,10 +170,47 @@ CI 中的 **Upgrade release gate** 要求真实 1.3.0 数据库在十个必测 容器,等待 MySQL 租户就绪,归档诊断并在测试失败时也清理容器,不依赖外部数据库凭据。 任一后端无法启动都使检查失败,不能跳过。历史拒绝行为回归不参与正向升级门禁。 仓库管理员需要在分支保护中将该检查设为必需。 -发布工作流只有验收通过才会上传发行包、发布 PyPI 和上传 Release 附件,并归档验收证据。工作流在 +默认发布工作流只有验收通过才会上传发行包、发布 PyPI 和上传 Release 附件,并归档验收证据。工作流在 `release.published` 后运行,无法阻止之前手动发布 GitHub Release 页面,因此必须先运行上述命令再发布页面。 `make publish` 要求提供 `UPGRADE_RELEASE_NOTES=/path/to/release-notes.md`,并在上传软件包之前执行相同门禁。 +### 手动跳过升级验收发布 + +**Release** 工作流提供 `workflow_dispatch` 参数 `skip_upgrade_acceptance`,默认 `false`。 +只有明确按“仅供新建数据库使用”发布、不声明已有数据库可升级时,才启用此选项。它不修改 +`release/upgrade-support.json`,不证明迁移路径可用,也不放宽默认发布流程和 PR 的升级门禁。 +启用时,目标版本的策略必须仍为 `upgrade-required`。 + +对于已经发布的 GitHub Release,例如 `powercontext-v1.4.1`: + +1. 在 Release 正文中加入以下英文原文;工作流会检查该声明: + + ```text + Upgrade acceptance was skipped for this release. Use only new databases; upgrades of existing databases are not supported. + ``` + + 中文可同时写明:本次发布跳过了升级验收,仅供新建数据库使用,不支持已有数据库升级。 + +2. 打开 **Actions → Release → Run workflow**,选择仓库默认分支 `master`,填写 + `release_tag=powercontext-v1.4.1`,勾选 `skip_upgrade_acceptance`。工作流修改需要先合并到默认分支。 + 通过参数指定目标 tag,工作流分支选择器仍选默认分支。 + + ```bash + gh workflow run release.yml --repo oceanbase/powercontext --ref master \ + -f release_tag=powercontext-v1.4.1 -f skip_upgrade_acceptance=true + ``` + +工作流将已有 tag 解析为提交并构建该提交,不移动 tag,也不打包当前默认分支。声明从 GitHub Release +实时读取,不复用失败运行的旧事件正文。版本一致性、发版引用同步、策略一致性、发行包元数据、源码包 +重建对比、干净环境安装、依赖检查以及新库 Server/MCP/Memory 冒烟验证仍须通过,之后才会将这些确切产物 +发布到 PyPI 并上传 Release 附件。发布后验证和官网部署继续执行,并使用选定的发布目标。 + +`release-without-upgrades` 附件记录 tag、源码提交、操作者、运行编号、发行包哈希及 +`upgrade_acceptance: skipped`;运行摘要也会注明已有数据库升级不受支持。 +未勾选该选项时,仍执行常规自动升级矩阵及支持声明检查。 + +### 通用发布验证 + 针对本次发布的完整变化运行检查,包括生成的契约和文档: ```bash diff --git a/scripts/parallel_upgrade_release_gate.py b/scripts/parallel_upgrade_release_gate.py index 6e6e3eb9ec..332a9c530d 100644 --- a/scripts/parallel_upgrade_release_gate.py +++ b/scripts/parallel_upgrade_release_gate.py @@ -65,7 +65,7 @@ def prepare( "format": 1, "execution_id": uuid.uuid4().hex, "workflow_run_id": os.environ.get("GITHUB_RUN_ID"), - "source_sha": os.environ.get("GITHUB_SHA"), + "source_sha": os.environ.get("PC_UPGRADE_SOURCE_SHA", os.environ.get("GITHUB_SHA")), "policy_sha256": gate.sha256(root / gate.MANIFEST), "target_version": gate.wheel_version(wheel), "target_wheel": wheel.name, @@ -96,7 +96,10 @@ def read_plan(root: Path, prepared: Path) -> tuple[dict[str, Any], Path]: plan = json.loads((prepared / "plan.json").read_text(encoding="utf-8")) gate.require(plan["format"] == 1, "Unknown parallel upgrade plan format") gate.require(plan["workflow_run_id"] == os.environ.get("GITHUB_RUN_ID"), "Evidence belongs to another workflow run") - gate.require(plan["source_sha"] == os.environ.get("GITHUB_SHA"), "Evidence belongs to another checkout") + gate.require( + plan["source_sha"] == os.environ.get("PC_UPGRADE_SOURCE_SHA", os.environ.get("GITHUB_SHA")), + "Evidence belongs to another checkout", + ) gate.require(plan["policy_sha256"] == gate.sha256(root / gate.MANIFEST), "Upgrade policy changed after preparation") inputs = prepared / "inputs" files = list(inputs.iterdir()) diff --git a/tests/test_ci_release_verification.py b/tests/test_ci_release_verification.py index abb9ffeea0..de702d04ea 100644 --- a/tests/test_ci_release_verification.py +++ b/tests/test_ci_release_verification.py @@ -15,10 +15,13 @@ from __future__ import annotations import importlib.util +import io +import json import os import shutil import subprocess import sys +import urllib.request from pathlib import Path from types import ModuleType @@ -126,6 +129,86 @@ def test_release_workflow_rejects_vcs_version_mismatch(run_release_step) -> None assert "does not match VCS version" in result.stderr +@pytest.fixture +def resolve_publication(tmp_path, monkeypatch): + """Execute the real metadata step against a local GitHub response fixture.""" + workflow = yaml.safe_load((Path(__file__).parents[1] / ".github/workflows/release.yml").read_text()) + command = workflow["jobs"]["resolve-release"]["steps"][0]["run"] + source = command.removeprefix("python - <<'PY'\n").removesuffix("PY\n") + tag = "powercontext-v1.4.1" + sha = "a" * 40 + release = {"tag_name": tag, "draft": False, "published_at": "2026-10-26T10:00:00Z", "body": "Release notes"} + output = tmp_path / "output" + for key, value in { + "RELEASE_TAG": tag, + "SKIP_UPGRADE_ACCEPTANCE": "false", + "GITHUB_EVENT_NAME": "workflow_dispatch", + "GITHUB_REF": "refs/heads/master", + "DEFAULT_BRANCH": "master", + "GITHUB_REPOSITORY": "oceanbase/powercontext", + "GITHUB_SHA": sha, + "GH_TOKEN": "fixture-token", + "RUNNER_TEMP": str(tmp_path), + "GITHUB_OUTPUT": str(output), + }.items(): + monkeypatch.setenv(key, value) + + def response(request, **_kwargs): + if "/releases/tags/" in request.full_url: + payload = release + else: + assert request.full_url.endswith("/commits/refs%2Ftags%2Fpowercontext-v1.4.1") + payload = {"sha": sha} + return io.BytesIO(json.dumps(payload).encode()) + + monkeypatch.setattr(urllib.request, "urlopen", response) + + def run(): + exec(compile(source, "release.yml:resolve-release", "exec"), {}) # noqa: S102 -- checked-in workflow code + return dict(line.split("=", 1) for line in output.read_text().splitlines()) + + return run, release, tmp_path + + +def test_manual_release_pins_tag_commit_and_freezes_current_notes(resolve_publication): + run, release, root = resolve_publication + assert run() == {"tag": release["tag_name"], "source_sha": "a" * 40} + assert json.loads((root / "release-metadata/event.json").read_text())["release"] == release + + +def test_upgrade_bypass_requires_explicit_new_database_notice(resolve_publication, monkeypatch): + run, release, root = resolve_publication + monkeypatch.setenv("SKIP_UPGRADE_ACCEPTANCE", "true") + with pytest.raises(SystemExit, match="Add this exact notice"): + run() + assert not (root / "output").exists() + release["body"] += ( + "\nUpgrade acceptance was skipped for this release. " + "Use only new databases; upgrades of existing databases are not supported." + ) + assert run()["source_sha"] == "a" * 40 + + +@pytest.mark.parametrize("invalid", ["automatic-bypass", "branch", "tag", "draft", "moved-tag"]) +def test_release_rejects_unintended_publication_target(resolve_publication, monkeypatch, invalid): + run, release, root = resolve_publication + if invalid == "automatic-bypass": + monkeypatch.setenv("GITHUB_EVENT_NAME", "release") + monkeypatch.setenv("SKIP_UPGRADE_ACCEPTANCE", "true") + elif invalid == "branch": + monkeypatch.setenv("GITHUB_REF", "refs/heads/feature") + elif invalid == "tag": + release["tag_name"] = "powercontext-v1.4.0" + elif invalid == "draft": + release["draft"] = True + else: + monkeypatch.setenv("GITHUB_EVENT_NAME", "release") + monkeypatch.setenv("GITHUB_SHA", "b" * 40) + with pytest.raises(SystemExit): + run() + assert not (root / "output").exists() + + @pytest.mark.skipif(smoke.os.name == "nt", reason="POSIX venv symlink regression") def test_release_smoke_resolves_console_script_from_verification_python(tmp_path) -> None: scripts = tmp_path / "verification" / "bin" diff --git a/tests/test_upgrade_release_gate.py b/tests/test_upgrade_release_gate.py index ae6bb5fabb..7c9beeefdd 100644 --- a/tests/test_upgrade_release_gate.py +++ b/tests/test_upgrade_release_gate.py @@ -412,13 +412,18 @@ def execute(_root, _wheel, case, _output): assert len(result["cases"]) == len(result["resolved_matrix"]["required_cases"]) == 3 -def test_publishing_jobs_depend_on_nonoptional_preupload_upgrade_gate(): +def test_publishing_jobs_require_upgrade_acceptance_unless_manually_bypassed(): root = Path(__file__).parents[1] workflow = yaml.safe_load((root / ".github/workflows/release.yml").read_text()) build = workflow["jobs"]["release-build"] assert build["uses"] == "./.github/workflows/upgrade-release.yml" assert build["with"]["release"] is True assert build["with"]["selection"] == "automatic" + assert build["with"]["skip_upgrade_acceptance"] == ( + "${{ github.event_name == 'workflow_dispatch' && inputs.skip_upgrade_acceptance }}" + ) + events = workflow.get("on", workflow.get(True)) # PyYAML's YAML 1.1 parser treats "on" as True. + assert events["workflow_dispatch"]["inputs"]["skip_upgrade_acceptance"]["default"] is False assert not build.get("continue-on-error") shared = yaml.safe_load((root / ".github/workflows/upgrade-release.yml").read_text())["jobs"] prepare = shared["prepare"]["steps"] @@ -434,7 +439,8 @@ def test_publishing_jobs_depend_on_nonoptional_preupload_upgrade_gate(): assert shared["collect"]["needs"] == ["prepare", "cases"] assert any('test "$CASE_JOBS_RESULT" = success' in step.get("run", "") for step in steps) for name in ("pypi-publish", "release-assets"): - assert workflow["jobs"][name]["needs"] == "release-build" + dependencies = workflow["jobs"][name]["needs"] + assert "release-build" in ([dependencies] if isinstance(dependencies, str) else dependencies) main = yaml.safe_load((root / ".github/workflows/master.yml").read_text())["jobs"] assert main["upgrade-acceptance"]["uses"] == build["uses"] verdict = main["upgrade-release-gate"] @@ -1042,6 +1048,22 @@ def test_parallel_workers_reject_another_run(parallel_reports, monkeypatch): parallel.collect(root, prepared, reports, root / "accepted") +def test_manual_release_evidence_tracks_target_checkout_not_workflow_commit(release, monkeypatch): + root, wheel, _ = release + dist = root / "dist" + dist.mkdir() + shutil.copyfile(wheel, dist / wheel.name) + monkeypatch.setenv("GITHUB_SHA", "workflow-commit") + monkeypatch.setenv("PC_UPGRADE_SOURCE_SHA", "release-commit") + prepared = root / "prepared" + parallel.prepare(root, dist, prepared, release=False, release_notes=None) + plan, _ = parallel.read_plan(root, prepared) + assert plan["source_sha"] == "release-commit" + monkeypatch.setenv("PC_UPGRADE_SOURCE_SHA", "another-release-commit") + with pytest.raises(gate.GateError, match="another checkout"): + parallel.read_plan(root, prepared) + + def test_parallel_failed_case_still_records_failure_and_allows_other_workers(release, monkeypatch): root, wheel, _ = release dist = root / "dist"