From d3dc9a1bbf1d6cf99ed5917d20d0043e0029248c Mon Sep 17 00:00:00 2001 From: knqiufan Date: Thu, 5 Nov 2026 01:09:35 +0800 Subject: [PATCH 1/2] build(desktop): publish and verify qualified releases --- .github/workflows/desktop-release.yml | 134 +++++ .../desktop-release-publication.md | 97 ++++ .../desktop-release-publication.md | 81 +++ scripts/desktop_publication_manifest.py | 422 +++++++++++++++ scripts/publish_desktop_release.py | 471 +++++++++++++++++ tests/test_desktop_release_publication.py | 500 ++++++++++++++++++ 6 files changed, 1705 insertions(+) create mode 100644 .github/workflows/desktop-release.yml create mode 100644 docs/en/development/desktop-release-publication.md create mode 100644 docs/zh/development/desktop-release-publication.md create mode 100644 scripts/desktop_publication_manifest.py create mode 100644 scripts/publish_desktop_release.py create mode 100644 tests/test_desktop_release_publication.py diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 0000000000..a93c862234 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,134 @@ +name: Desktop release publication + +on: + workflow_dispatch: + inputs: + operation: + description: Publish and verify a prerelease, or promote an already verified prerelease + type: choice + options: [prerelease, promote] + default: prerelease + release_tag: + description: Existing protected Desktop tag + type: string + required: true + source_sha: + description: Exact tagged source commit (40 lowercase hex characters) + type: string + required: true + candidate_run_id: + description: Successful 09-A candidate run at the tagged commit + type: string + required: true + candidate_artifact_id: + description: Immutable Actions artifact ID for the original 09-A candidate manifest + type: string + required: true + staging_run_id: + description: Successful upstream signing handoff run on master + type: string + required: true + staging_artifact_id: + description: Immutable Actions artifact ID for the signing handoff + type: string + required: true + release_manifest_sha256: + description: Approved SHA-256 of desktop-release.json + type: string + required: true + provisioning_evidence_sha256: + description: For promote only, approved SHA-256 of desktop-provisioning-evidence.json + type: string + required: false + +permissions: {} + +# Serialize all publication, including runs supplied with a different spelling or tag. +concurrency: + group: desktop-release-publication + cancel-in-progress: false + +jobs: + prepare: + if: >- + github.repository == 'oceanbase/powercontext' && + github.ref == 'refs/heads/master' && github.ref_protected && + github.event.repository.default_branch == 'master' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + actions: read + contents: read + outputs: + run_id: ${{ steps.gate.outputs.run_id }} + artifact_id: ${{ steps.gate.outputs.artifact_id }} + candidate_run_id: ${{ steps.gate.outputs.candidate_run_id }} + candidate_artifact_id: ${{ steps.gate.outputs.candidate_artifact_id }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + - name: Check tag, staging provenance, and release environment + id: gate + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/publish_desktop_release.py gate + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + github-token: ${{ github.token }} + run-id: ${{ steps.gate.outputs.run_id }} + artifact-ids: ${{ steps.gate.outputs.artifact_id }} + path: ${{ runner.temp }}/desktop-staging + skip-decompress: true + digest-mismatch: error + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + github-token: ${{ github.token }} + run-id: ${{ steps.gate.outputs.candidate_run_id }} + artifact-ids: ${{ steps.gate.outputs.candidate_artifact_id }} + path: ${{ runner.temp }}/desktop-candidate + skip-decompress: true + digest-mismatch: error + - name: Validate exact bytes and display approval inputs + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/publish_desktop_release.py inspect + + publish: + if: >- + github.repository == 'oceanbase/powercontext' && + github.ref == 'refs/heads/master' && github.ref_protected && + github.event.repository.default_branch == 'master' + needs: prepare + runs-on: ubuntu-24.04 + timeout-minutes: 60 + environment: desktop-release + permissions: + actions: read + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + github-token: ${{ github.token }} + run-id: ${{ needs.prepare.outputs.run_id }} + artifact-ids: ${{ needs.prepare.outputs.artifact_id }} + path: ${{ runner.temp }}/desktop-staging + skip-decompress: true + digest-mismatch: error + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + github-token: ${{ github.token }} + run-id: ${{ needs.prepare.outputs.candidate_run_id }} + artifact-ids: ${{ needs.prepare.outputs.candidate_artifact_id }} + path: ${{ runner.temp }}/desktop-candidate + skip-decompress: true + digest-mismatch: error + - name: Publish or promote the approved assets and verify public downloads + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/publish_desktop_release.py publish diff --git a/docs/en/development/desktop-release-publication.md b/docs/en/development/desktop-release-publication.md new file mode 100644 index 0000000000..ba1c3be63d --- /dev/null +++ b/docs/en/development/desktop-release-publication.md @@ -0,0 +1,97 @@ +--- +title: Desktop release publication +description: Publish, verify, and promote the exact qualified Desktop packages and local setup resources. +--- + +# Desktop release publication + +[09-B in #2112](https://github.com/oceanbase/powercontext/issues/2112) publishes already qualified files. +09-A supplies the original normal product candidate manifest, and +[09-C](managed-resource-production.md) supplies the pinned resource inputs. Platform maintainers own the final +Windows signing and installed acceptance ([#2108](https://github.com/oceanbase/powercontext/issues/2108)) and macOS +signing, notarization, stapling and installed acceptance ([#2109](https://github.com/oceanbase/powercontext/issues/2109)). +A passing build, Actions artifact, fixture or unsigned candidate does not establish publication eligibility. + +## Configure the release boundary + +The release maintainer dispatches **Desktop release publication** from protected upstream `master` only. Configure +the `desktop-release` environment with **protected branches only**, required reviewers, prevention of self-review, +and administrator bypass disabled. Assign a release reviewer and a named acceptance owner per target. Protect +`desktop-v*` tags from changes or deletion; create the tag at the approved Desktop commit on `master` before dispatch. +The workflow never intentionally creates or moves a tag. It checks the live tag against the approved commit before +writes and after public downloads. Only the approved job receives a `contents: write` workflow token; no signing key +or personal token is required by 09-B. The Python release workflow and its tags are independent. + +Use the small `desktop-candidate-manifest--` artifact from a successful, manually dispatched +`desktop-candidate.yml` run at the exact tagged commit. Its only member is the original +`desktop-release-candidate.json`; keep the full candidate artifact for signing and audit. +Provide the exact final files as an immutable Actions artifact named `desktop-signed-release-handoff` from a +successful maintainer signing handoff dispatched on upstream `master`. The platform producer must stage this artifact +(for example with `actions/upload-artifact` in the protected signing workflow). 09-B does not implement signing, +produce platform approval records, or accept arbitrary URLs or handoffs from other branches or repositories. + +## Build the final handoff + +Windows x64, macOS arm64 and macOS x86_64 are required, each with its own installer, Server archive and catalog, +independent Ops archive and bundle manifest, and provision descriptor. Ubuntu x86_64 can be selected only when +[#2111](https://github.com/oceanbase/powercontext/issues/2111) and the matching candidate/resource producers +supply that same qualified inventory. Omit Linux entirely when it is not qualified. Do not advertise it. + +Final resources and catalogs must be assembled before their dependent package is signed and accepted. On macOS, +sign nested code before freezing archives and their manifests. Give the catalog the final +`https://github.com/oceanbase/powercontext/releases/download//` URL and allow the GitHub and +`https://release-assets.githubusercontent.com` origins. Recompute the catalog, helper and provision pins and +embed the final pins before signing. Report the three installed pins read from the final installed package and +independent Ops bundle; the candidate catalog or its placeholder origin is never a release input. Check that the +installed app and helper can bootstrap a clean machine without system Python, using the chosen Server profile, +and complete the required first-use and recovery outcomes. 09-B checks the binding to exact bytes. The approver +must inspect the platform owners' actual signature and installed acceptance reports. + +The signing handoff ZIP must contain only flat regular files, no directory entries, with at most 6 GiB of uncompressed +payload. Use the final asset names from 09-A (the basename of each candidate path). Its `desktop-release.json` +contains these fields: + +| Object | Fields | +| --- | --- | +| Root | `format_version: 1`, `kind: "qualified-desktop-release"`, `release_tag`, Desktop `source_sha`, `candidate_run_id`, `candidate_artifact_id`, `candidate_manifest_sha256`, `targets` keyed by target ID. | +| Each target | `assets` keyed by `desktop`, `server_archive`, `server_catalog`, `ops_archive`, `ops_provision`, `ops_bundle_manifest`; each has `name`, integer `size` in bytes, and lowercase `sha256`. `candidate_asset_sha256` has the same keys, with the original candidate digests. | +| Each target's `qualification` | `kind: "installed-product"`, `signing` (`authenticode`, `developer-id-notarized-stapled`, or `linux-qualified`), `clean_machine_provisioning: "passed"`, `asset_sha256` for all final roles, `installed_catalog_sha256`, `installed_ops_provision_sha256`, `installed_ops_bundle_manifest_sha256`, `os_version`, `webview_version`, `server_version`, `runtime_version` (the pinned Python version), `limitations`, `report` (upstream issue/comment or Actions run URL), and `server`. | +| `qualification.server` | `kind: "qualified-product-server"`, `id` from the candidate's matching product records, upstream `report` URL, `wheel_sha256`, `contract_sha256`, and exact Server `source_sha` (can differ from the Desktop commit). | + +The maintainer should check that the reported Windows signer and macOS Developer ID/notarization results cover +the final installers and required executable resources. The workflow rejects Windows/macOS installers whose bytes still match the unsigned candidate. +The compatibility record must cover the exact Server wheel and +Desktop operations. If 09-A reports no matching product record, qualify the actual inputs first; do not edit the +candidate manifest or substitute a different wheel. + +## Prerelease, retry, and promotion + +1. Dispatch `prerelease` with the existing tag, Desktop commit, candidate and staging run/artifact IDs, and + SHA-256 of `desktop-release.json`. The read-only job validates the inputs and lists the final digests and reports + in its run summary. An independent reviewer checks those reports before approving the environment. +2. The writer fills a draft Release. A rerun for the same identity skips existing assets with the exact expected + size and SHA-256. It may remove a zero-byte GitHub `starter` entry left by a failed upload on that draft. It + never replaces an uploaded file. An unexpected asset, identity, or digest stops the run; investigate rather than + overwrite. If qualified bytes must change, use a new Desktop version and tag. Keep final signed inputs and + evidence available while retrying; Actions artifacts expire. +3. Only after the complete draft inventory matches does the workflow make it a public **prerelease**. It downloads + each file, including the two manifests and generated `SHA256SUMS.txt`, into a new directory from its real public + asset URL, without credentials, and checks the size and SHA-256. A public download failure leaves the same + prerelease in place; rerunning verifies it without modifying its inventory. +4. On each included target, download the installer from that prerelease and exercise local setup on a clean machine + against the published resources. Record the actual installer URL and the digests used in the test, and retain + the reports. Restage the **same** final files and byte-identical release manifest, with one extra + `desktop-provisioning-evidence.json` file. Its root has `format_version: 1`, + `kind: "downloaded-desktop-provisioning"`, integer `release_id`, `release_manifest_sha256`, and `targets` + for every published target. Each target records `download_url`, all final role digests in `asset_sha256`, + `clean_machine_provisioning: "passed"`, and the upstream `report` URL. +5. Dispatch `promote` with the same release identity, new staging IDs, and the approved provisioning evidence + SHA-256. The independent reviewer checks downloaded-installer evidence. The workflow verifies public files + again before it clears the prerelease flag. It records the evidence digest and links in the Release notes and + leaves the assets and tag intact. A failed or cancelled run never defaults to stable. + +Keep published tags and resource assets available for installers that pin their URLs and digests. Archive the accepted evidence beyond the Actions artifact retention period. Do not +manually swap, delete, or reuse a tag to fix a release. If an asset must be withdrawn, coordinate the support and +recovery plan with the platform and Installer owners before removing a pinned resource. The workflow does not +perform platform installation itself, and local fake-HTTP tests are not evidence for #2112's real prerelease +or downloaded provisioning acceptance. diff --git a/docs/zh/development/desktop-release-publication.md b/docs/zh/development/desktop-release-publication.md new file mode 100644 index 0000000000..b7105d51ff --- /dev/null +++ b/docs/zh/development/desktop-release-publication.md @@ -0,0 +1,81 @@ +--- +title: Desktop 发布 +description: 发布、回下载验证并转正已经验收的 Desktop 安装包和本机设置资源。 +--- + +# Desktop 发布 + +[#2112 的 09-B](https://github.com/oceanbase/powercontext/issues/2112) 负责发布已经验收的文件。 +09-A 提供正常产品构建的原始候选清单,[09-C](managed-resource-production.md) 提供固定版本的资源输入。 +平台维护者分别负责 Windows 正式签名和装机验收([#2108](https://github.com/oceanbase/powercontext/issues/2108)), +以及 macOS 签名、公证、附加公证票据和装机验收([#2109](https://github.com/oceanbase/powercontext/issues/2109))。 +构建通过、Actions 制品、测试夹具和未签名候选都不能当作正式发布资格。 + +## 设置发布边界 + +发布维护者只从受保护的上游 `master` 手动触发 **Desktop release publication**。为 +`desktop-release` 环境设置仅允许受保护分支、要求审核人审批、禁止自审以及禁止管理员绕过。 +指定发布审核人和各平台验收负责人。保护 `desktop-v*` 标签,防止移动和删除;触发前,将标签指向 +`master` 上已批准的 Desktop 提交。流程不主动创建或移动标签,在写入前和回下载后核对标签与提交。 +只有审批后的任务获得 `contents: write` 工作流令牌。09-B 不需要签名密钥或个人令牌, +与 Python 发布工作流及其标签独立。 + +从标签指向的提交上成功手动运行的 `desktop-candidate.yml` 中,选择小型制品 +`desktop-candidate-manifest--`。它只包含原始 `desktop-release-candidate.json`; +完整候选保留供签名和核查。最终文件通过上游 `master` 成功运行的平台签名交接流程提交, +使用名为 `desktop-signed-release-handoff` 的不可变 Actions 制品,例如由受保护的签名工作流用 +`actions/upload-artifact` 上传。09-B 不实现签名、不生成平台验收记录,也不接受任意 URL 或来自其他分支、仓库的文件来代替此交接。 + +## 准备最终文件 + +Windows x64、macOS arm64 和 macOS x86_64 必须齐全。每个平台都要提交安装包、Server 归档与目录清单、 +独立 Ops 归档与包清单,以及预置描述文件。只有 [#2111](https://github.com/oceanbase/powercontext/issues/2111) +以及对应候选和资源生产方能提供同样齐备的合格清单时,才选入 Ubuntu x86_64。Linux 未合格时直接省略, +不要对外宣称支持。 + +先组装最终资源和目录清单,再签名并验收依赖它们的安装包。macOS 的嵌套代码须在冻结归档和清单前签名。 +Server 目录清单使用最终的 `https://github.com/oceanbase/powercontext/releases/download//`, +并允许 GitHub 和 `https://release-assets.githubusercontent.com` 两个来源。重新计算目录清单、helper +和预置描述文件的摘要,嵌入最终摘要后签名。从最终安装的应用和独立 Ops 包中读取三个实际摘要并记录; +候选目录清单及其中的占位来源均不可用于发布。在无系统 Python 的干净机上,用选定的 Server 资格配置 +验证本机设置、首次使用和恢复。09-B 核对记录与最终字节的绑定,审核人仍须检查平台实际签名和装机验收报告。 + +签名交接 ZIP 只包含平铺的普通文件,不含目录项,解压体积最多 6 GiB。使用 09-A 各候选路径的文件名 +作为正式资产名。其 `desktop-release.json` 包含: + +| 对象 | 字段 | +| --- | --- | +| 根 | `format_version: 1`、`kind: "qualified-desktop-release"`、`release_tag`、Desktop `source_sha`、`candidate_run_id`、`candidate_artifact_id`、`candidate_manifest_sha256`、按目标 ID 索引的 `targets`。 | +| 各目标 | `assets` 按 `desktop`、`server_archive`、`server_catalog`、`ops_archive`、`ops_provision`、`ops_bundle_manifest` 索引;各项有 `name`、整数字节数 `size`、小写 `sha256`。`candidate_asset_sha256` 按相同角色记录原始候选摘要。 | +| 各目标的 `qualification` | `kind: "installed-product"`、`signing`(`authenticode`、`developer-id-notarized-stapled` 或 `linux-qualified`)、`clean_machine_provisioning: "passed"`、全量最终 `asset_sha256`、`installed_catalog_sha256`、`installed_ops_provision_sha256`、`installed_ops_bundle_manifest_sha256`、`os_version`、`webview_version`、`server_version`、`runtime_version`(固定 Python 版本)、`limitations`、`report`(上游 issue、评论或 Actions run URL)以及 `server`。 | +| `qualification.server` | `kind: "qualified-product-server"`、候选清单中匹配的产品资格 `id`、上游 `report` URL、`wheel_sha256`、`contract_sha256`、确切的 Server `source_sha`(可以与 Desktop 提交不同)。 | + +审核人应检查 Windows 签名者和 macOS Developer ID、公证结果确实覆盖最终安装包及所需可执行资源。 +流程会拒绝字节仍与未签名候选相同的 Windows/macOS 安装包。兼容记录必须覆盖实际 Server wheel 和 Desktop 操作。09-A 没有匹配的产品资格记录时, +先对真实输入进行验收;不要改写候选清单或替换 wheel。 + +## 预发布、重试和转正 + +1. 手动触发 `prerelease`,输入已有标签、Desktop 提交、候选和签名交接的 run ID 与 artifact ID, + 以及 `desktop-release.json` 的 SHA-256。只读任务先校验输入,在运行摘要列出最终摘要和报告。 + 独立审核人检查报告后审批环境。 +2. 写入任务先补齐 Release 草稿。同一身份重跑时,已存在且体积和 SHA-256 都相同的文件会保留。 + 仅在草稿上允许移除 GitHub 失败上传留下的零字节 `starter` 项。已上传的文件从不替换。 + 遇到意外资产、身份或摘要时,停止并排查。如果验收过的字节需要修改,使用新的 Desktop 版本和标签。 + 重试期间保留最终签名文件和证据,Actions 制品会过期。 +3. 草稿资产齐备且匹配后,才开放为公开 **prerelease**。流程在新目录中从真实公开资产 URL + 无凭据地逐个下载全部文件,包括两份清单和生成的 `SHA256SUMS.txt`,检查体积及 SHA-256。 + 回下载失败时保留同一 prerelease;重跑只复验,不改动公开资产。 +4. 在每个纳入目标上,从该 prerelease 下载安装包,在干净机器上用已发布资源完成本机设置。 + 记录实际安装包 URL 和测试使用的摘要,保留报告。重新交接**同一批最终文件和字节完全相同的发布清单**, + 只增加 `desktop-provisioning-evidence.json`。其根字段为 `format_version: 1`、 + `kind: "downloaded-desktop-provisioning"`、整数 `release_id`、`release_manifest_sha256`, + 以及包含所有已发布目标的 `targets`。各目标有 `download_url`、所有最终角色的 + `asset_sha256`、`clean_machine_provisioning: "passed"` 和上游 `report` URL。 +5. 手动触发 `promote`,输入相同发布身份、新交接 ID 和已批准的回下载验收记录 SHA-256。 + 独立审核人检查实际下载安装后的证据。流程再次复验公开文件,然后取消 prerelease 标记。 + Release 说明记录验收摘要和链接,资产及标签保持原样。运行失败或取消后不会自动转正。 + +保留已发布的标签和资源资产,供固定 URL 和摘要的安装程序使用。验收证据须在 Actions 制品到期后继续归档。不要手动替换、删除文件或复用标签来修复发布。 +确需撤回时,先与平台和 Installer 负责人安排用户支持和恢复。此流程不自行装机; +本地模拟 HTTP 测试也不能替代 #2112 的真实预发布或下载安装验收。 diff --git a/scripts/desktop_publication_manifest.py b/scripts/desktop_publication_manifest.py new file mode 100644 index 0000000000..7c7bd51066 --- /dev/null +++ b/scripts/desktop_publication_manifest.py @@ -0,0 +1,422 @@ +# 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. + +"""Validate the byte and evidence handoff for Desktop release publication.""" + +from __future__ import annotations + +import hashlib +import json +import re +import stat +from dataclasses import dataclass +from pathlib import Path +from typing import Any, BinaryIO +from zipfile import ZipFile + +REPOSITORY = "oceanbase/powercontext" +CANDIDATE = "desktop-release-candidate.json" +MANIFEST = "desktop-release.json" +PROVISIONING = "desktop-provisioning-evidence.json" +CHECKSUMS = "SHA256SUMS.txt" +REQUIRED = {"windows-x86_64", "macos-arm64", "macos-x86_64"} +OPTIONAL = {"linux-x86_64"} +SUFFIX = {"windows-x86_64": ".exe", "macos-arm64": ".dmg", "macos-x86_64": ".dmg", "linux-x86_64": ".deb"} +SIGNING = { + "windows-x86_64": "authenticode", + "macos-arm64": "developer-id-notarized-stapled", + "macos-x86_64": "developer-id-notarized-stapled", + "linux-x86_64": "linux-qualified", +} +ROLES = {"desktop", "server_archive", "server_catalog", "ops_archive", "ops_provision", "ops_bundle_manifest"} +SHA = r"[0-9a-f]{64}" +COMMIT = r"[0-9a-f]{40}" +TAG_PREFIX = "desktop-v" +TAG = TAG_PREFIX + r"(?:0|[1-9][0-9]{0,4})(?:\.(?:0|[1-9][0-9]{0,4})){2}" +NAME = r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}" +REPORT = r"https://github\.com/oceanbase/powercontext/(?:issues/[1-9][0-9]*(?:#issuecomment-[1-9][0-9]*)?|actions/runs/[1-9][0-9]*)" +MAX_ASSET = 2 * 1024**3 - 1 # GitHub per-asset limit +MAX_STAGED = 6 * 1024**3 +MAX_JSON = 2 * 1024**2 + + +class PublicationError(RuntimeError): + """Release input or remote state violated the publication contract.""" + + +def require(condition: object, message: str) -> None: + if not condition: + raise PublicationError(message) + + +def matching(value: Any, pattern: str, label: str) -> str: + if not isinstance(value, str) or not re.fullmatch(pattern, value): + raise PublicationError(f"Invalid {label}") # noqa: TRY003 + return value + + +def unique_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + require(key not in result, "Duplicate key in release JSON") + result[key] = value + return result + + +def read_json(path: Path, *, max_bytes: int = MAX_JSON) -> dict[str, Any]: + require(path.stat().st_size <= max_bytes, f"{path.name} exceeds the JSON size limit") + value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_keys) + require(isinstance(value, dict), f"{path.name} must contain a JSON object") + return value + + +@dataclass(frozen=True) +class Asset: + name: str + size: int + sha256: str + + @classmethod + def parse(cls, value: dict[str, Any]) -> Asset: + name = matching(value["name"], NAME, "asset filename") + require(not name.endswith(".") and ".." not in name, "Unsafe asset filename") + size = value["size"] + require(type(size) is int and 0 < size <= MAX_ASSET, f"Invalid size for {name}") + return cls(name, size, matching(value["sha256"], SHA, "asset SHA-256")) + + @classmethod + def from_file(cls, path: Path) -> Asset: + with path.open("rb") as stream: + return cls(path.name, path.stat().st_size, hashlib.file_digest(stream, "sha256").hexdigest()) + + +def verify_bytes(stream: BinaryIO, asset: Asset, output: BinaryIO | None = None) -> None: + digest = hashlib.sha256() + received = 0 + while chunk := stream.read(1024**2): + received += len(chunk) + require(received <= asset.size, f"Unexpected size for {asset.name}") + digest.update(chunk) + if output is not None: + output.write(chunk) + require(received == asset.size and digest.hexdigest() == asset.sha256, f"Byte verification failed: {asset.name}") + + +def extract_stage(archive: Path, destination: Path, *, only: str | None = None) -> None: + """Extract only a flat, bounded set of inert regular files into an empty directory.""" + with ZipFile(archive) as bundle: + entries = bundle.infolist() + if only is not None: + require( + len(entries) == 1 and entries[0].filename == only and entries[0].file_size <= MAX_JSON, + "Unexpected contents in the 09-A manifest artifact", + ) + require(0 < len(entries) <= 60, "Invalid staged asset count") + require(sum(entry.file_size for entry in entries) <= MAX_STAGED, "Staging exceeds the total size limit") + seen: set[str] = set() + for entry in entries: + name = matching(entry.filename, NAME, "staged filename") + require(not name.endswith(".") and ".." not in name, "Unsafe staged filename") + require(name.casefold() not in seen, "Duplicate staged asset filename") + require( + stat.S_IFMT(entry.external_attr >> 16) in (0, stat.S_IFREG) and 0 < entry.file_size <= MAX_ASSET, + f"Staging member must be a bounded regular file: {name}", + ) + seen.add(name.casefold()) + with bundle.open(entry) as source, (destination / name).open("xb") as output: + while data := source.read(1024**2): + output.write(data) + + +def asset_url(tag: str, name: str) -> str: + return f"https://github.com/{REPOSITORY}/releases/download/{tag}/{name}" + + +@dataclass(frozen=True) +class Bundle: + root: Path + tag: str + source_sha: str + manifest_sha256: str + targets: dict[str, dict[str, Asset]] + assets: dict[str, Asset] + qualification_reports: dict[str, str] + server_reports: dict[str, str] + server_source_sha: str + candidate_sha256: str + + @property + def release_marker(self) -> str: + return f"" + + def notes(self) -> str: + qualified = "\n".join( + f"- `{target}`: [platform acceptance]({report})" + for target, report in sorted(self.qualification_reports.items()) + ) + return ( + f"{self.release_marker}\n\n" + f"Qualified Desktop packages and pinned local Server/Ops resources for `{self.source_sha}`.\n\n" + f"{qualified}\n\n" + "Local setup requires network access. Use only the platform packages listed here. " + "See desktop-release.json for exact support and installation evidence, and SHA256SUMS.txt for file hashes.\n" + ) + + +def validate_platform(record: dict[str, Any], target: str, assets: dict[str, Asset]) -> str: + require(record["kind"] == "installed-product", f"{target}: native product evidence required") + require(record["signing"] == SIGNING[target], f"{target}: missing required signing/notarization evidence") + require( + record["asset_sha256"] == {role: asset.sha256 for role, asset in assets.items()}, + f"{target}: qualification must bind every final asset", + ) + require(record["clean_machine_provisioning"] == "passed", f"{target}: clean-machine provisioning is required") + require( + record["installed_catalog_sha256"] == assets["server_catalog"].sha256 + and record["installed_ops_provision_sha256"] == assets["ops_provision"].sha256 + and record["installed_ops_bundle_manifest_sha256"] == assets["ops_bundle_manifest"].sha256, + f"{target}: installed signed package must bind the published catalog and Ops pins", + ) + for field in ("os_version", "webview_version", "server_version", "runtime_version", "limitations"): + require(isinstance(record[field], str) and 0 < len(record[field]) <= 500, f"{target}: missing {field}") + return matching(record["report"], REPORT, "platform acceptance report URL") + + +def validate_catalog( + root: Path, tag: str, target: str, assets: dict[str, Asset], qualification: dict[str, Any] +) -> None: + catalog = read_json(root / assets["server_catalog"].name, max_bytes=32 * 1024**2) + require( + catalog["format_version"] == 1 and len(catalog["resources"]) == 1, + f"{target}: expected one pinned Server resource", + ) + spec = catalog["resources"][0] + archive = assets["server_archive"] + os_name, architecture = target.split("-", 1) + require( + spec["platform"] == os_name + and spec["architecture"] == architecture + and spec["server_version"] == qualification["server_version"] + and spec["archive_sha256"] == archive.sha256 + and spec["archive_size"] == archive.size + and spec["url"] == asset_url(tag, archive.name), + f"{target}: catalog does not identify the final published Server resource", + ) + require( + spec["qualification_ref"] == qualification["server"]["id"], + f"{target}: catalog qualification differs from the approved Server qualification", + ) + require( + f"git+https://github.com/{REPOSITORY}@{qualification['server']['source_sha']}" in spec["source_refs"] + and spec["python_version"] == qualification["runtime_version"], + f"{target}: catalog does not identify the approved Server commit and interpreter", + ) + require( + set(spec["allowed_origins"]) == {"https://github.com", "https://release-assets.githubusercontent.com"}, + f"{target}: catalog must permit the GitHub asset origin and its download redirect", + ) + + +def validate_provision(root: Path, target: str, assets: dict[str, Asset]) -> None: + spec = read_json(root / assets["ops_provision"].name) + require( + spec + == { + "format_version": 1, + "binding_mode": "managed_catalog", + "catalog_sha256": assets["server_catalog"].sha256, + "bundle_manifest_sha256": assets["ops_bundle_manifest"].sha256, + }, + f"{target}: Ops provision descriptor does not pin the final catalog and helper", + ) + + +def candidate_assets(origin: dict[str, Any], target: str, version: str) -> dict[str, Asset]: + entries = { + **origin["assets"], + "ops_provision": origin["provenance"]["ops_provision"], + "ops_bundle_manifest": origin["provenance"]["ops_bundle_manifest"], + } + require(entries.keys() == ROLES, f"{target}: incomplete candidate asset inventory") + result = {} + for role, entry in entries.items(): + name = entry["name"] + require(isinstance(name, str) and name.startswith(target + "/"), f"{target}: invalid candidate asset path") + result[role] = Asset.parse({**entry, "name": name.removeprefix(target + "/")}) + require( + result["desktop"].name == f"powercontext-desktop-{version}-{target}{SUFFIX[target]}", + f"{target}: installer is not the normal product package", + ) + return result + + +def validate_server_qualification( + target: str, qualification: dict[str, Any], origin: dict[str, Any], candidate: dict[str, Any] +) -> None: + server = qualification["server"] + ref = matching(server["id"], NAME, "Server qualification ID") + require( + not ref.lower().startswith(("ci-", "fixture", "unqualified", "candidate")), + f"{target}: fixture or unqualified Server is not publishable", + ) + matching(server["report"], REPORT, "Server qualification report URL") + require( + any(record["id"] == ref for record in origin["qualification"]["matching_product_records"]), + f"{target}: Desktop does not carry a matching product qualification for this Server", + ) + require( + server["kind"] == "qualified-product-server" + and server["wheel_sha256"] + == matching(origin["provenance"]["server_wheel"]["sha256"], SHA, "Server wheel SHA-256") + and server["contract_sha256"] == matching(candidate["contract"]["sha256"], SHA, "Desktop contract SHA-256") + and server["source_sha"] == matching(candidate["server_source_sha"], COMMIT, "Server source commit") + and qualification["server_version"] == origin["runtime"]["server_version"] == candidate["server_version"] + and qualification["runtime_version"] == origin["runtime"]["python_version"], + f"{target}: product qualification does not identify the candidate Server wheel and Desktop contract", + ) + + +def validate_bundle( + root: Path, + tag: str, + source: str, + manifest_sha: str, + *, + candidate_run_id: int, + candidate_artifact_id: int, + promote: bool, +) -> Bundle: + matching(tag, TAG, "Desktop release tag") + matching(source, COMMIT, "source commit") + matching(manifest_sha, SHA, "release manifest SHA-256") + manifest_asset = Asset.from_file(root / MANIFEST) + require(manifest_asset.sha256 == manifest_sha, "Release manifest differs from the approved digest") + manifest = read_json(root / MANIFEST) + require( + manifest["format_version"] == 1 and manifest["kind"] == "qualified-desktop-release", + "Unsupported release manifest", + ) + require((manifest["release_tag"], manifest["source_sha"]) == (tag, source), "Release identity mismatch") + + candidate_asset = Asset.from_file(root / CANDIDATE) + require( + candidate_asset.sha256 == manifest["candidate_manifest_sha256"] + and manifest["candidate_run_id"] == candidate_run_id + and manifest["candidate_artifact_id"] == candidate_artifact_id, + "Candidate manifest identity mismatch", + ) + candidate = read_json(root / CANDIDATE) + candidate_targets = candidate["targets"] + require( + candidate["schema_version"] == 1 + and candidate["kind"] == "desktop-product-candidate" + and candidate["status"] == "unqualified_candidate" + and candidate["publishable"] is False, + "Expected the normal 09-A product candidate manifest", + ) + version = tag.removeprefix(TAG_PREFIX) + require( + (candidate["release_tag"], candidate["source_sha"], candidate["version"]) == (tag, source, version), + "Candidate identity mismatch", + ) + + records = manifest["targets"] + require(REQUIRED <= records.keys() <= REQUIRED | OPTIONAL, "Required Windows and both macOS targets are mandatory") + require( + records.keys() <= candidate_targets.keys() <= REQUIRED | OPTIONAL, "Unrecognized or missing candidate target" + ) + targets: dict[str, dict[str, Asset]] = {} + assets = {manifest_asset.name: manifest_asset, candidate_asset.name: candidate_asset} + names = {MANIFEST.casefold(), CANDIDATE.casefold(), PROVISIONING.casefold(), CHECKSUMS.casefold()} + reports = {} + server_reports = {} + for target, record in records.items(): + origin = candidate_targets[target] + originals = candidate_assets(origin, target, version) + require( + origin["build_kind"] == "product" and origin["qualification"]["status"] == "unqualified_candidate", + f"{target}: unexpected candidate qualification status", + ) + qualification = record["qualification"] + validate_server_qualification(target, qualification, origin, candidate) + final = {role: Asset.parse(value) for role, value in record["assets"].items()} + require(final.keys() == ROLES, f"{target}: incomplete final asset inventory") + if target in REQUIRED: + require( + final["desktop"].sha256 != originals["desktop"].sha256, + f"{target}: the unsigned candidate installer cannot be a signed release asset", + ) + require( + record["candidate_asset_sha256"] == {role: asset.sha256 for role, asset in originals.items()}, + f"{target}: signing handoff must identify every original candidate asset", + ) + for role in sorted(ROLES): + require(final[role].name == originals[role].name, f"{target}: final {role} has wrong filename") + for asset in final.values(): + require(asset.name.casefold() not in names, "Duplicate release asset filename") + names.add(asset.name.casefold()) + require(Asset.from_file(root / asset.name) == asset, f"Staged bytes differ: {asset.name}") + assets[asset.name] = asset + targets[target] = final + reports[target] = validate_platform(qualification, target, final) + server_reports[target] = qualification["server"]["report"] + validate_catalog(root, tag, target, final, qualification) + validate_provision(root, target, final) + + expected = set(assets) | ({PROVISIONING} if promote else set()) + require({file.name for file in root.iterdir()} == expected, "Unexpected files in signing handoff") + checksums = root / CHECKSUMS + with checksums.open("x", encoding="utf-8", newline="\n") as output: + for name, asset in sorted(assets.items()): + output.write(f"{asset.sha256} {name}\n") + assets[CHECKSUMS] = Asset.from_file(checksums) + return Bundle( + root, + tag, + source, + manifest_sha, + targets, + assets, + reports, + server_reports, + candidate["server_source_sha"], + candidate_asset.sha256, + ) + + +def validate_provisioning(bundle: Bundle, evidence_sha: str, release_id: int) -> dict[str, str]: + path = bundle.root / PROVISIONING + matching(evidence_sha, SHA, "provisioning evidence SHA-256") + require(Asset.from_file(path).sha256 == evidence_sha, "Provisioning evidence differs from the approved digest") + evidence = read_json(path) + require( + evidence["format_version"] == 1 and evidence["kind"] == "downloaded-desktop-provisioning", + "Unsupported provisioning evidence", + ) + require( + (evidence["release_id"], evidence["release_manifest_sha256"]) == (release_id, bundle.manifest_sha256), + "Provisioning evidence is for a different prerelease", + ) + require(evidence["targets"].keys() == bundle.targets.keys(), "Missing downloaded provisioning evidence") + reports = {} + for target, assets in bundle.targets.items(): + record = evidence["targets"][target] + require( + record["download_url"] == asset_url(bundle.tag, assets["desktop"].name) + and record["asset_sha256"] == {role: asset.sha256 for role, asset in assets.items()}, + f"{target}: downloaded provisioning evidence does not match published assets", + ) + require(record["clean_machine_provisioning"] == "passed", f"{target}: downloaded provisioning must pass") + reports[target] = matching(record["report"], REPORT, "downloaded provisioning report URL") + return reports diff --git a/scripts/publish_desktop_release.py b/scripts/publish_desktop_release.py new file mode 100644 index 0000000000..16582acdbe --- /dev/null +++ b/scripts/publish_desktop_release.py @@ -0,0 +1,471 @@ +# 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. + +"""Publish an approved Desktop signing handoff without rebuilding or replacing assets.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlsplit +from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener +from zipfile import BadZipFile + +from desktop_publication_manifest import ( + CANDIDATE, + COMMIT, + MAX_STAGED, + REPOSITORY, + SHA, + TAG, + TAG_PREFIX, + Asset, + Bundle, + PublicationError, + asset_url, + extract_stage, + matching, + require, + validate_bundle, + validate_provisioning, + verify_bytes, +) + +ENVIRONMENT = "desktop-release" +POSITIVE_ID = r"[1-9][0-9]{0,19}" +API = f"https://api.github.com/repos/{REPOSITORY}" +UPLOAD = f"https://uploads.github.com/repos/{REPOSITORY}" +DOWNLOAD_HOSTS = {"release-assets.githubusercontent.com", "objects.githubusercontent.com"} + + +class NoRedirect(HTTPRedirectHandler): + """Never forward the write token or request body through a redirect.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # ty: ignore[missing-override-decorator] + return None + + +class PublicRedirect(HTTPRedirectHandler): + """Follow only GitHub's public asset CDN redirects, without credentials.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # ty: ignore[missing-override-decorator] + parts = urlsplit(newurl) + require(parts.scheme == "https" and parts.netloc in DOWNLOAD_HOSTS, "Unexpected public asset redirect") + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +@dataclass(frozen=True) +class Dispatch: + tag: str + source_sha: str + operation: str + run_id: int + artifact_id: int + candidate_run_id: int + candidate_artifact_id: int + manifest_sha256: str + provisioning_sha256: str + + @property + def promote(self) -> bool: + return self.operation == "promote" + + @classmethod + def current(cls) -> Dispatch: + require( + os.environ.get("GITHUB_REPOSITORY") == REPOSITORY + and os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch" + and os.environ.get("GITHUB_REF") == "refs/heads/master" + and os.environ.get("GITHUB_REF_PROTECTED") == "true", + "Desktop release requires explicit dispatch on protected upstream master", + ) + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8")) + require(event["repository"]["default_branch"] == "master", "Unexpected upstream default branch") + inputs = event["inputs"] + dispatch = cls( + matching(inputs["release_tag"], TAG, "Desktop release tag"), + matching(inputs["source_sha"], COMMIT, "source commit"), + matching(inputs["operation"], r"prerelease|promote", "release operation"), + int(matching(inputs["staging_run_id"], POSITIVE_ID, "staging run ID")), + int(matching(inputs["staging_artifact_id"], POSITIVE_ID, "staging artifact ID")), + int(matching(inputs["candidate_run_id"], POSITIVE_ID, "candidate run ID")), + int(matching(inputs["candidate_artifact_id"], POSITIVE_ID, "candidate manifest artifact ID")), + matching(inputs["release_manifest_sha256"], SHA, "release manifest SHA-256"), + inputs.get("provisioning_evidence_sha256") or "", + ) + if dispatch.promote: + matching(dispatch.provisioning_sha256, SHA, "downloaded provisioning evidence SHA-256") + else: + require(not dispatch.provisioning_sha256, "Provisioning evidence is for promote only") + return dispatch + + +class GitHub: + def __init__(self, token: str) -> None: + self.token = token + self.api = build_opener(ProxyHandler({}), NoRedirect()) + self.public = build_opener(ProxyHandler({}), PublicRedirect()) + + def _request( + self, url: str, method: str = "GET", *, payload: dict[str, Any] | None = None, file: Path | None = None + ) -> Any: + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self.token}", + "X-GitHub-Api-Version": "2022-11-28", + } + try: + if file is not None: + headers.update({"Content-Type": "application/octet-stream", "Content-Length": str(file.stat().st_size)}) + with ( + file.open("rb") as stream, + self.api.open( + Request(url, data=stream, headers=headers, method=method), # noqa: S310 + timeout=120, + ) as response, + ): + return json.load(response) + if payload is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(payload).encode("utf-8") if payload is not None else None + with self.api.open(Request(url, data=data, headers=headers, method=method), timeout=30) as response: # noqa: S310 + return None if response.status == 204 else json.load(response) + except HTTPError as error: + raise PublicationError(f"GitHub {method} {urlsplit(url).path} returned HTTP {error.code}") from error # noqa: TRY003 + + def request(self, path: str, method: str = "GET", payload: dict[str, Any] | None = None) -> Any: + return self._request(f"{API}/{path}", method, payload=payload) + + def tag_commit(self, tag: str) -> str: + ref = quote("refs/tags/" + tag, safe="") + return self.request(f"commits/{ref}")["sha"] + + def release(self, tag: str) -> dict[str, Any] | None: + # Listing with the write token includes drafts, including partially staged drafts. + page = 1 + matches = [] + while True: + releases = self.request(f"releases?per_page=100&page={page}") + matches.extend(item for item in releases if item["tag_name"] == tag) + if len(releases) < 100: + break + page += 1 + require(len(matches) <= 1, "Multiple GitHub Releases use this Desktop tag") + return matches[0] if matches else None + + def create_draft(self, bundle: Bundle) -> dict[str, Any]: + return self.request( + "releases", + "POST", + { + "tag_name": bundle.tag, + "target_commitish": bundle.source_sha, + "name": f"PowerContext Desktop {bundle.tag.removeprefix(TAG_PREFIX)}", + "body": bundle.notes(), + "draft": True, + "prerelease": True, + "make_latest": "false", + }, + ) + + def assets(self, release_id: int) -> list[dict[str, Any]]: + rows = self.request(f"releases/{release_id}/assets?per_page=100") + require(len(rows) < 100, "GitHub Release has more assets than a Desktop release can contain") + return rows + + def upload(self, release_id: int, file: Path) -> dict[str, Any]: + return self._request( + f"{UPLOAD}/releases/{release_id}/assets?name={quote(file.name, safe='')}", "POST", file=file + ) + + def remove_empty_upload(self, asset_id: int) -> None: + self.request(f"releases/assets/{asset_id}", "DELETE") + + def update(self, release_id: int, **fields: Any) -> dict[str, Any]: + return self.request(f"releases/{release_id}", "PATCH", {**fields, "make_latest": "false"}) + + def download(self, tag: str, asset: Asset, destination: Path) -> None: + with ( + self.public.open(asset_url(tag, asset.name), timeout=120) as response, + destination.open("xb") as output, + ): + verify_bytes(response, asset, output) + + +def verify_tag(github: GitHub, tag: str, source_sha: str) -> None: + require(github.tag_commit(tag) == source_sha, "Desktop tag does not resolve to the approved source commit") + + +def is_ancestor(source: str, head: str) -> bool: + return ( + subprocess.run( # noqa: S603 + ["git", "merge-base", "--is-ancestor", source, head], # noqa: S607 + check=False, + ).returncode + == 0 + ) + + +def verify_artifact(github: GitHub, run_id: int, artifact_id: int, expected_name: str) -> dict[str, Any]: + run = github.request(f"actions/runs/{run_id}") + require( + run["id"] == run_id + and run["conclusion"] == "success" + and run["event"] == "workflow_dispatch" + and run["head_branch"] == "master" + and run["head_repository"]["full_name"] == REPOSITORY, + "Handoffs must come from successful explicit runs on upstream master", + ) + artifact = github.request(f"actions/artifacts/{artifact_id}") + require( + artifact["id"] == artifact_id + and artifact["name"] == expected_name + and 0 < artifact["size_in_bytes"] <= MAX_STAGED + and not artifact["expired"] + and artifact["workflow_run"]["id"] == run_id, + "Selected artifact does not belong to the approved handoff run", + ) + matching(artifact.get("digest"), "sha256:" + SHA, "Actions artifact digest") + return run + + +def gate(github: GitHub, dispatch: Dispatch) -> None: + head = matching(os.environ.get("GITHUB_SHA"), COMMIT, "workflow commit") + require(is_ancestor(dispatch.source_sha, head), "Tagged source is not in the protected master history") + verify_tag(github, dispatch.tag, dispatch.source_sha) + environment = github.request(f"environments/{ENVIRONMENT}") + policy = environment.get("deployment_branch_policy") or {} + require(policy.get("protected_branches") is True, "desktop-release environment must allow protected branches only") + require( + any( + rule.get("type") == "required_reviewers" + and rule.get("prevent_self_review") is True + and rule.get("reviewers") + for rule in environment["protection_rules"] + ), + "desktop-release environment requires independent human reviewers", + ) + + version = dispatch.tag.removeprefix(TAG_PREFIX) + candidate = verify_artifact( + github, + dispatch.candidate_run_id, + dispatch.candidate_artifact_id, + f"desktop-candidate-manifest-{version}-{dispatch.source_sha}", + ) + require( + candidate["path"] == ".github/workflows/desktop-candidate.yml" and candidate["head_sha"] == dispatch.source_sha, + "Candidate manifest must originate from the 09-A workflow at the approved source commit", + ) + stage = verify_artifact(github, dispatch.run_id, dispatch.artifact_id, "desktop-signed-release-handoff") + require( + is_ancestor(matching(stage["head_sha"], COMMIT, "staging workflow commit"), head), + "Signing handoff workflow commit is not in the protected master history", + ) + + +def release_id_for(release: dict[str, Any], bundle: Bundle) -> int: + require( + release["tag_name"] == bundle.tag and (release.get("body") or "").splitlines()[:1] == [bundle.release_marker], + "Existing GitHub Release belongs to another manifest; refusing to modify it", + ) + require(type(release["id"]) is int and release["id"] > 0, "Invalid GitHub Release ID") + return release["id"] + + +def check_remote_asset(row: dict[str, Any], asset: Asset, bundle: Bundle, *, draft: bool) -> None: + require( + row["name"] == asset.name + and row["state"] == "uploaded" + and row["size"] == asset.size + and row.get("digest") == f"sha256:{asset.sha256}" + and (draft or row["browser_download_url"] == asset_url(bundle.tag, asset.name)), + f"Conflicting GitHub asset: {asset.name}. Existing uploaded bytes will not be replaced.", + ) + + +def remote_inventory( + github: GitHub, release_id: int, bundle: Bundle, *, draft: bool +) -> tuple[set[str], dict[str, int]]: + uploaded: set[str] = set() + empty_uploads: dict[str, int] = {} + for row in github.assets(release_id): + name = row["name"] + require( + name in bundle.assets and name not in uploaded and name not in empty_uploads, + "Unexpected or duplicate GitHub Release asset", + ) + if draft and row["state"] == "starter" and row["size"] == 0 and not row.get("digest"): + empty_uploads[name] = int(matching(str(row["id"]), POSITIVE_ID, "empty asset ID")) + else: + check_remote_asset(row, bundle.assets[name], bundle, draft=draft) + uploaded.add(name) + return uploaded, empty_uploads + + +def stage_draft(github: GitHub, release_id: int, bundle: Bundle) -> None: + uploaded, empty_uploads = remote_inventory(github, release_id, bundle, draft=True) + for name in sorted(bundle.assets.keys() - uploaded): + verify_tag(github, bundle.tag, bundle.source_sha) + if name in empty_uploads: + # A failed GitHub upload can leave a zero-byte "starter". This is + # not an uploaded asset; never delete a successfully uploaded row. + github.remove_empty_upload(empty_uploads[name]) + row = github.upload(release_id, bundle.root / name) + check_remote_asset(row, bundle.assets[name], bundle, draft=True) + uploaded, empty_uploads = remote_inventory(github, release_id, bundle, draft=True) + require( + uploaded == bundle.assets.keys() and not empty_uploads, "Draft does not contain the complete approved inventory" + ) + + +def verify_public(github: GitHub, release_id: int, bundle: Bundle) -> None: + verify_tag(github, bundle.tag, bundle.source_sha) + uploaded, _ = remote_inventory(github, release_id, bundle, draft=False) + require(uploaded == bundle.assets.keys(), "Published release is missing approved assets") + with tempfile.TemporaryDirectory(prefix="desktop-release-download-") as directory: + for asset in bundle.assets.values(): + path = Path(directory) / asset.name + github.download(bundle.tag, asset, path) + path.unlink() + verify_tag(github, bundle.tag, bundle.source_sha) + uploaded, _ = remote_inventory(github, release_id, bundle, draft=False) + require(uploaded == bundle.assets.keys(), "Release inventory changed during public download verification") + + +def publish(github: GitHub, dispatch: Dispatch, bundle: Bundle) -> int: + verify_tag(github, bundle.tag, bundle.source_sha) + release = github.release(bundle.tag) + if release is None: + require(not dispatch.promote, "Publish and verify a prerelease before promotion") + release = github.create_draft(bundle) + release_id = release_id_for(release, bundle) + + if dispatch.promote: + require(not release["draft"], "Publish and verify a prerelease before promotion") + reports = validate_provisioning(bundle, dispatch.provisioning_sha256, release_id) + marker = f"" + if not release["prerelease"]: + require(marker in release["body"].splitlines(), "Release already promoted with different evidence") + verify_public(github, release_id, bundle) + if release["prerelease"]: + links = "\n".join( + f"- `{target}`: [downloaded installer acceptance]({url})" for target, url in sorted(reports.items()) + ) + verify_tag(github, bundle.tag, bundle.source_sha) + updated = github.update(release_id, prerelease=False, body=f"{release['body']}\n{marker}\n\n{links}\n") + require( + release_id_for(updated, bundle) == release_id and not updated["draft"] and not updated["prerelease"], + "GitHub did not promote the approved prerelease", + ) + else: + if release["draft"]: + require(release["prerelease"], "Refusing to publish a draft without the prerelease flag") + stage_draft(github, release_id, bundle) + verify_tag(github, bundle.tag, bundle.source_sha) + updated = github.update(release_id, draft=False, prerelease=True) + require( + release_id_for(updated, bundle) == release_id and not updated["draft"] and updated["prerelease"], + "GitHub did not publish the approved prerelease", + ) + verify_public(github, release_id, bundle) + return release_id + + +def inspect(github: GitHub, dispatch: Dispatch, bundle: Bundle) -> None: + lines = [ + f"Desktop {dispatch.operation}: `{bundle.tag}`", + f"- Desktop source: `{bundle.source_sha}`", + f"- Server source: `{bundle.server_source_sha}`", + f"- Candidate manifest: `{bundle.candidate_sha256}`", + f"- Release manifest: `{bundle.manifest_sha256}`", + ] + for target, report in sorted(bundle.qualification_reports.items()): + lines.append( + f"- `{target}`: [platform acceptance]({report}) · [Server qualification]({bundle.server_reports[target]})" + ) + lines.extend(f" - `{asset.name}`: `{asset.sha256}`" for asset in bundle.targets[target].values()) + if dispatch.promote: + release = github.release(bundle.tag) + if release is None or release["draft"]: + raise PublicationError("Promotion needs a public prerelease") # noqa: TRY003 + for target, report in sorted( + validate_provisioning(bundle, dispatch.provisioning_sha256, release_id_for(release, bundle)).items() + ): + lines.append(f"- `{target}`: [downloaded installer acceptance]({report})") + lines.append(f"- Downloaded provisioning evidence: `{dispatch.provisioning_sha256}`") + with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as output: + output.write("\n".join(lines) + "\n") + + +def raw_archive(directory: Path) -> Path: + paths = list(directory.iterdir()) + require(len(paths) == 1 and paths[0].is_file(), f"Expected one immutable ZIP in {directory.name}") + return paths[0] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("step", choices=["gate", "inspect", "publish"]) + step = parser.parse_args().step + try: + dispatch = Dispatch.current() + github = GitHub(os.environ["GH_TOKEN"]) + if step == "gate": + gate(github, dispatch) + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: + output.write( + f"run_id={dispatch.run_id}\nartifact_id={dispatch.artifact_id}\n" + f"candidate_run_id={dispatch.candidate_run_id}\n" + f"candidate_artifact_id={dispatch.candidate_artifact_id}\n" + ) + return + temp = Path(os.environ["RUNNER_TEMP"]) + with tempfile.TemporaryDirectory(prefix="desktop-release-staged-", dir=temp) as directory: + root = Path(directory) + extract_stage(raw_archive(temp / "desktop-staging"), root) + require(not (root / CANDIDATE).exists(), "Signing handoff must not supply a candidate manifest") + extract_stage(raw_archive(temp / "desktop-candidate"), root, only=CANDIDATE) + bundle = validate_bundle( + root, + dispatch.tag, + dispatch.source_sha, + dispatch.manifest_sha256, + candidate_run_id=dispatch.candidate_run_id, + candidate_artifact_id=dispatch.candidate_artifact_id, + promote=dispatch.promote, + ) + if step == "inspect": + inspect(github, dispatch, bundle) + return + # Recheck even after environment approval, before any authenticated write. + gate(github, dispatch) + release_id = publish(github, dispatch, bundle) + print(f"Verified public assets for {bundle.tag} (Release {release_id})") + except PublicationError as error: + raise SystemExit(str(error)) from None + except (BadZipFile, KeyError, ValueError, AttributeError, TypeError, OSError, URLError) as error: + # Never echo staged JSON, server response bodies, or signed redirect URLs. + raise SystemExit(f"Desktop publication could not complete: {type(error).__name__}") from None # noqa: TRY003 + + +if __name__ == "__main__": + main() diff --git a/tests/test_desktop_release_publication.py b/tests/test_desktop_release_publication.py new file mode 100644 index 0000000000..262556b0bf --- /dev/null +++ b/tests/test_desktop_release_publication.py @@ -0,0 +1,500 @@ +# 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 the Desktop release boundary with synthetic bytes and a local GitHub stand-in.""" + +from __future__ import annotations + +import json +import threading +from dataclasses import asdict, replace +from hashlib import sha256 +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlsplit +from zipfile import ZipFile + +import publish_desktop_release as publication +import pytest +from desktop_publication_manifest import ( + CANDIDATE, + MANIFEST, + PROVISIONING, + REPOSITORY, + REQUIRED, + SIGNING, + SUFFIX, + Asset, + Bundle, + PublicationError, + asset_url, + extract_stage, + validate_bundle, +) + +TAG = "desktop-v1.2.3" +SOURCE = "a" * 40 +SERVER_SOURCE = "c" * 40 +CONTRACT_SHA = "b" * 64 +WHEEL_SHA = "d" * 64 +REPORT = f"https://github.com/{REPOSITORY}/issues/2112" +QUALIFICATION_ID = "sqlite-product-v1" +RUN = 123 +ARTIFACT = 456 + + +def write_json(path: Path, value: dict[str, Any]) -> Asset: + path.write_text(json.dumps(value) + "\n", encoding="utf-8") + return Asset.from_file(path) + + +def draft_handoff(root: Path) -> tuple[dict[str, Any], dict[str, Any]]: + candidate = { + "schema_version": 1, + "kind": "desktop-product-candidate", + "status": "unqualified_candidate", + "publishable": False, + "release_tag": TAG, + "version": "1.2.3", + "source_sha": SOURCE, + "server_source_sha": SERVER_SOURCE, + "server_version": "1.5.0", + "contract": {"sha256": CONTRACT_SHA}, + "targets": {}, + } + release = { + "format_version": 1, + "kind": "qualified-desktop-release", + "release_tag": TAG, + "source_sha": SOURCE, + "candidate_run_id": RUN, + "candidate_artifact_id": ARTIFACT, + "targets": {}, + } + for target in sorted(REQUIRED): + os_name, arch = target.split("-", 1) + names = { + "desktop": f"powercontext-desktop-1.2.3-{target}{SUFFIX[target]}", + "server_archive": f"powercontext-server-1.2.3-{target}.tar.gz", + "server_catalog": f"powercontext-server-1.2.3-{target}.json", + "ops_archive": f"powercontext-ops-1.2.3-{target}.tar.gz", + "ops_provision": f"powercontext-ops-provision-1.2.3-{target}.json", + "ops_bundle_manifest": f"powercontext-ops-bundle-1.2.3-{target}.json", + } + for role in ("desktop", "server_archive", "ops_archive", "ops_bundle_manifest"): + (root / names[role]).write_bytes(f"test bytes for {target} {role}".encode()) + server_archive = Asset.from_file(root / names["server_archive"]) + catalog = write_json( + root / names["server_catalog"], + { + "format_version": 1, + "resources": [ + { + "platform": os_name, + "architecture": arch, + "server_version": "1.5.0", + "python_version": "3.12.16", + "archive_size": server_archive.size, + "archive_sha256": server_archive.sha256, + "url": asset_url(TAG, server_archive.name), + "allowed_origins": ["https://github.com", "https://release-assets.githubusercontent.com"], + "source_refs": [f"git+https://github.com/{REPOSITORY}@{SERVER_SOURCE}"], + "qualification_ref": QUALIFICATION_ID, + } + ], + }, + ) + write_json( + root / names["ops_provision"], + { + "format_version": 1, + "binding_mode": "managed_catalog", + "catalog_sha256": catalog.sha256, + "bundle_manifest_sha256": Asset.from_file(root / names["ops_bundle_manifest"]).sha256, + }, + ) + records = {role: asdict(Asset.from_file(root / name)) for role, name in names.items()} + originals = { + role: {**asset, "name": f"{target}/{asset['name']}", "sha256": "e" * 64} for role, asset in records.items() + } + candidate["targets"][target] = { + "build_kind": "product", + "runtime": {"server_version": "1.5.0", "python_version": "3.12.16"}, + "qualification": { + "status": "unqualified_candidate", + "matching_product_records": [{"id": QUALIFICATION_ID}], + }, + "assets": { + role: originals[role] for role in ("desktop", "server_archive", "server_catalog", "ops_archive") + }, + "provenance": { + "server_wheel": {"sha256": WHEEL_SHA}, + **{role: originals[role] for role in ("ops_provision", "ops_bundle_manifest")}, + }, + } + release["targets"][target] = { + "assets": records, + "candidate_asset_sha256": {role: asset["sha256"] for role, asset in originals.items()}, + "qualification": { + "kind": "installed-product", + "signing": SIGNING[target], + "clean_machine_provisioning": "passed", + "asset_sha256": {role: asset["sha256"] for role, asset in records.items()}, + "installed_catalog_sha256": records["server_catalog"]["sha256"], + "installed_ops_provision_sha256": records["ops_provision"]["sha256"], + "installed_ops_bundle_manifest_sha256": records["ops_bundle_manifest"]["sha256"], + "os_version": "test OS", + "webview_version": "test WebView", + "server_version": "1.5.0", + "runtime_version": "3.12.16", + "limitations": "test limitation", + "report": REPORT, + "server": { + "kind": "qualified-product-server", + "id": QUALIFICATION_ID, + "report": REPORT, + "wheel_sha256": WHEEL_SHA, + "contract_sha256": CONTRACT_SHA, + "source_sha": SERVER_SOURCE, + }, + }, + } + return candidate, release + + +def freeze(root: Path, candidate: dict[str, Any], release: dict[str, Any]) -> str: + release["candidate_manifest_sha256"] = write_json(root / CANDIDATE, candidate).sha256 + return write_json(root / MANIFEST, release).sha256 + + +def validate(root: Path, digest: str, *, promote: bool = False) -> Bundle: + return validate_bundle( + root, TAG, SOURCE, digest, candidate_run_id=RUN, candidate_artifact_id=ARTIFACT, promote=promote + ) + + +@pytest.mark.parametrize( + ("fault", "error"), + [ + ("missing-intel", "Required Windows"), + ("fixture", "fixture or unqualified"), + ("empty-qualified-profile", "does not carry a matching product qualification"), + ("wrong-wheel", "does not identify the candidate Server wheel"), + ("desktop-source-as-server-source", "does not identify the candidate Server wheel"), + ("missing-ops", "incomplete final asset"), + ("mismatched-installed-ops", "must bind the published catalog and Ops"), + ("wrong-original", "identify every original candidate asset"), + ("unsigned-candidate", "unsigned candidate installer"), + ("corrupt", "Staged bytes differ"), + ("stale-catalog-url", "catalog does not identify"), + ("candidate-origin", "catalog must permit"), + ("duplicate-name", "Duplicate release asset"), + ("manifest-mismatch", "differs from the approved digest"), + ], +) +def test_bad_handoffs_fail_before_publication(tmp_path, fault, error): # noqa: C901 + candidate, release = draft_handoff(tmp_path) + target = "macos-x86_64" + record = release["targets"][target] + candidate_target = candidate["targets"][target] + if fault == "missing-intel": + del release["targets"][target] + elif fault == "fixture": + record["qualification"]["server"]["id"] = "ci-legacy-fixture" + elif fault == "empty-qualified-profile": + candidate_target["qualification"]["matching_product_records"] = [] + elif fault == "wrong-wheel": + record["qualification"]["server"]["wheel_sha256"] = "0" * 64 + elif fault == "desktop-source-as-server-source": + record["qualification"]["server"]["source_sha"] = SOURCE + elif fault == "missing-ops": + del record["assets"]["ops_archive"] + elif fault == "mismatched-installed-ops": + record["qualification"]["installed_ops_provision_sha256"] = "0" * 64 + elif fault == "wrong-original": + record["candidate_asset_sha256"]["desktop"] = "0" * 64 + elif fault == "unsigned-candidate": + candidate_target["assets"]["desktop"]["sha256"] = record["assets"]["desktop"]["sha256"] + record["candidate_asset_sha256"]["desktop"] = record["assets"]["desktop"]["sha256"] + elif fault == "corrupt": + (tmp_path / record["assets"]["desktop"]["name"]).write_bytes(b"changed") + elif fault in {"stale-catalog-url", "candidate-origin"}: + path = tmp_path / record["assets"]["server_catalog"]["name"] + catalog = json.loads(path.read_bytes()) + if fault == "stale-catalog-url": + catalog["resources"][0]["url"] = "https://candidate.invalid/server.tar.gz" + else: + catalog["resources"][0]["allowed_origins"].append("https://candidate.invalid") + asset = write_json(path, catalog) + record["assets"]["server_catalog"] = asdict(asset) + record["qualification"]["asset_sha256"]["server_catalog"] = asset.sha256 + record["qualification"]["installed_catalog_sha256"] = asset.sha256 + elif fault == "duplicate-name": + other = release["targets"]["macos-arm64"]["assets"]["ops_provision"] + candidate_target["provenance"]["ops_provision"]["name"] = f"{target}/{other['name']}" + record["assets"]["ops_provision"] = other + digest = freeze(tmp_path, candidate, release) + with pytest.raises(PublicationError, match=error): + validate(tmp_path, "0" * 64 if fault == "manifest-mismatch" else digest) + + +def test_staged_zip_does_not_write_outside_destination(tmp_path): + archive = tmp_path / "unsafe.zip" + output = tmp_path / "extracted" + output.mkdir() + with ZipFile(archive, "w") as bundle: + bundle.writestr("../overwrite", "escape") + with pytest.raises(PublicationError, match="Invalid staged filename"): + extract_stage(archive, output) + assert not (tmp_path / "overwrite").exists() + + +class FakeRelease: + def __init__(self): + self.release = None + self.assets = {} + self.payloads = {} + self.upload_attempts = [] + self.writes = [] + self.downloads = [] + self.fail_upload = None + self.corrupt_download = None + self.tag_sha = SOURCE + + def row(self, name, data, *, state="uploaded"): + return { + "id": len(self.assets) + 1, + "name": name, + "size": len(data), + "state": state, + "digest": f"sha256:{sha256(data).hexdigest()}" if state == "uploaded" else None, + "browser_download_url": asset_url(TAG, name), + } + + +@pytest.fixture +def fake_github(monkeypatch): # noqa: C901 + fake = FakeRelease() + + class Handler(BaseHTTPRequestHandler): + def respond(self, status, data=None): + self.send_response(status) + if data is None: + self.end_headers() + else: + body = data if isinstance(data, bytes) else json.dumps(data).encode() + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - stdlib parameter name + pass + + def do_GET(self): + url = urlsplit(self.path) + if url.path.startswith("/api/commits/"): + self.respond(200, {"sha": fake.tag_sha}) + elif url.path == "/api/releases": + self.respond(200, [fake.release] if fake.release else []) + elif url.path == "/api/releases/42/assets": + self.respond(200, list(fake.assets.values())) + elif url.path.startswith(f"/{REPOSITORY}/releases/download/{TAG}/"): + assert "Authorization" not in self.headers + assert fake.release is not None and not fake.release["draft"] + name = url.path.rsplit("/", 1)[-1] + fake.downloads.append(name) + self.respond(200, b"corrupt" if name == fake.corrupt_download else fake.payloads[name]) + else: + self.respond(404) + + def do_POST(self): + assert self.headers["Authorization"] == "Bearer local-test-token" + url = urlsplit(self.path) + data = self.rfile.read(int(self.headers["Content-Length"])) + if url.path == "/api/releases": + fake.release = {"id": 42, **json.loads(data)} + fake.writes.append("create") + self.respond(201, fake.release) + return + assert url.path == "/upload/releases/42/assets" + name = parse_qs(url.query)["name"][0] + fake.upload_attempts.append(name) + if name in fake.assets: + self.respond(422) + elif name == fake.fail_upload: + fake.assets[name] = fake.row(name, b"", state="starter") + self.respond(502) + else: + fake.assets[name] = fake.row(name, data) + fake.payloads[name] = data + self.respond(201, fake.assets[name]) + + def do_DELETE(self): + asset_id = int(self.path.rsplit("/", 1)[-1]) + [name] = [name for name, row in fake.assets.items() if row["id"] == asset_id] + assert fake.assets[name]["state"] == "starter" + del fake.assets[name] + fake.writes.append("delete-empty") + self.respond(204) + + def do_PATCH(self): + assert self.path == "/api/releases/42" + assert fake.release is not None + data = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + fake.release.update(data) + fake.writes.append("update") + self.respond(200, fake.release) + + with ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server: + root = f"http://127.0.0.1:{server.server_port}" + monkeypatch.setattr(publication, "API", f"{root}/api") + monkeypatch.setattr(publication, "UPLOAD", f"{root}/upload") + github = publication.GitHub("local-test-token") + real_open = github.public.open + monkeypatch.setattr(github.public, "open", lambda url, **kwargs: real_open(root + urlsplit(url).path, **kwargs)) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield github, fake + finally: + server.shutdown() + thread.join(timeout=5) + + +@pytest.fixture +def signed_bundle(tmp_path): + candidate, release = draft_handoff(tmp_path) + return validate(tmp_path, freeze(tmp_path, candidate, release)) + + +def dispatch_for(bundle): + return publication.Dispatch(TAG, SOURCE, "prerelease", 100, 101, RUN, ARTIFACT, bundle.manifest_sha256, "") + + +def test_retry_recovers_partial_draft_and_preserves_uploaded_bytes(fake_github, signed_bundle): + github, fake = fake_github + dispatch = dispatch_for(signed_bundle) + first, second, *_ = sorted(signed_bundle.assets) + fake.fail_upload = second + with pytest.raises(PublicationError, match="HTTP 502"): + publication.publish(github, dispatch, signed_bundle) + assert fake.release["draft"] and not fake.downloads + assert fake.assets[first]["state"] == "uploaded" and fake.assets[second]["state"] == "starter" + + fake.fail_upload = None + assert publication.publish(github, dispatch, signed_bundle) == 42 + assert not fake.release["draft"] and fake.release["prerelease"] + assert fake.upload_attempts.count(first) == 1 + assert fake.upload_attempts.count(second) == 2 + assert fake.writes == ["create", "delete-empty", "update"] + assert set(fake.downloads) == set(signed_bundle.assets) + assert set(signed_bundle.targets) == REQUIRED + + publication.publish(github, dispatch, signed_bundle) + assert fake.writes == ["create", "delete-empty", "update"] + + +def test_draft_conflict_is_detected_before_uploading_anything(fake_github, signed_bundle): + github, fake = fake_github + github.create_draft(signed_bundle) + name = next(iter(signed_bundle.assets)) + fake.assets[name] = fake.row(name, b"conflicting uploaded bytes") + with pytest.raises(PublicationError, match="will not be replaced"): + publication.publish(github, dispatch_for(signed_bundle), signed_bundle) + assert fake.writes == ["create"] and not fake.upload_attempts + + +def test_moved_tag_is_rejected_before_any_mutation(fake_github, signed_bundle): + github, fake = fake_github + fake.tag_sha = SERVER_SOURCE + with pytest.raises(PublicationError, match="does not resolve"): + publication.publish(github, dispatch_for(signed_bundle), signed_bundle) + assert not fake.writes and not fake.upload_attempts + + +def test_public_corruption_blocks_promotion_until_verified(fake_github, signed_bundle): + github, fake = fake_github + dispatch = dispatch_for(signed_bundle) + fake.corrupt_download = next(iter(signed_bundle.assets)) + with pytest.raises(PublicationError, match="Byte verification failed"): + publication.publish(github, dispatch, signed_bundle) + assert fake.release["prerelease"] and not fake.release["draft"] + + evidence = { + "format_version": 1, + "kind": "downloaded-desktop-provisioning", + "release_id": 42, + "release_manifest_sha256": signed_bundle.manifest_sha256, + "targets": { + target: { + "download_url": asset_url(TAG, assets["desktop"].name), + "asset_sha256": {role: asset.sha256 for role, asset in assets.items()}, + "clean_machine_provisioning": "passed", + "report": REPORT, + } + for target, assets in signed_bundle.targets.items() + }, + } + path = signed_bundle.root / PROVISIONING + wrong = write_json(path, {**evidence, "release_id": 43}) + promote = replace(dispatch, operation="promote", provisioning_sha256=wrong.sha256) + with pytest.raises(PublicationError, match="different prerelease"): + publication.publish(github, promote, signed_bundle) + + evidence_sha = write_json(path, evidence).sha256 + promote = replace(promote, provisioning_sha256=evidence_sha) + with pytest.raises(PublicationError, match="Byte verification failed"): + publication.publish(github, promote, signed_bundle) + assert fake.release["prerelease"] + assert fake.writes == ["create", "update"] + + fake.corrupt_download = None + publication.publish(github, promote, signed_bundle) + assert not fake.release["prerelease"] + assert evidence_sha in fake.release["body"] + assert fake.writes == ["create", "update", "update"] + publication.publish(github, promote, signed_bundle) + assert fake.writes == ["create", "update", "update"] + + +def test_optional_candidate_does_not_block_required_publication(tmp_path): + candidate, release = draft_handoff(tmp_path) + candidate["targets"]["linux-x86_64"] = {"build_kind": "unqualified-optional"} + bundle = validate(tmp_path, freeze(tmp_path, candidate, release)) + assert set(bundle.targets) == REQUIRED + + +def test_unprotected_dispatch_is_rejected_before_loading_inputs(monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", REPOSITORY) + monkeypatch.setenv("GITHUB_EVENT_NAME", "workflow_dispatch") + monkeypatch.setenv("GITHUB_REF", "refs/heads/master") + monkeypatch.setenv("GITHUB_REF_PROTECTED", "false") + monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False) + with pytest.raises(PublicationError, match="protected upstream master"): + publication.Dispatch.current() + + +def test_public_redirect_cannot_use_a_non_github_origin(): + from urllib.request import Request + + with pytest.raises(PublicationError, match="Unexpected public asset redirect"): + publication.PublicRedirect().redirect_request( + Request(asset_url(TAG, "example.dmg")), # noqa: S310 - fixed GitHub URL, no request sent + None, + 302, + "", + {}, + "https://release-assets.githubusercontent.com.example.com/file", + ) From f302dfd4f8c421a1dafc0071c0fe073f4dd6e304 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Fri, 6 Nov 2026 00:30:18 +0800 Subject: [PATCH 2/2] fix(gateway): restore lifecycle license header --- gateway/crates/pc-gateway-core/src/lifecycle.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/gateway/crates/pc-gateway-core/src/lifecycle.rs b/gateway/crates/pc-gateway-core/src/lifecycle.rs index aa0c25af7d..b56696ccaf 100644 --- a/gateway/crates/pc-gateway-core/src/lifecycle.rs +++ b/gateway/crates/pc-gateway-core/src/lifecycle.rs @@ -1,3 +1,17 @@ +// 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. + use crate::ManagedOutcome; use std::sync::{Arc, Mutex, MutexGuard}; use tokio::sync::watch;