From 66b8057910501e6e1e6580befbe1dd6fc70be465 Mon Sep 17 00:00:00 2001 From: evanlowe <62918515+evanlowe@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:18:59 +0800 Subject: [PATCH 1/4] feat(studio): broadcast release notes to Feishu groups --- .github/workflows/publish-studio-release.yaml | 59 +- frontend/README.md | 7 + .../service/studio_release_notifier/README.md | 69 ++ .../studio_release_notifier/__init__.py | 13 + .../service/studio_release_notifier/app.py | 346 ++++++++++ .../service/studio_release_notifier/deploy.py | 633 ++++++++++++++++++ .../studio_release_notifier/requirements.txt | 4 + .../service/studio_release_notifier/run.sh | 5 + .../studio_release_notifier/test_app.py | 139 ++++ 9 files changed, 1274 insertions(+), 1 deletion(-) create mode 100644 frontend/service/studio_release_notifier/README.md create mode 100644 frontend/service/studio_release_notifier/__init__.py create mode 100644 frontend/service/studio_release_notifier/app.py create mode 100644 frontend/service/studio_release_notifier/deploy.py create mode 100644 frontend/service/studio_release_notifier/requirements.txt create mode 100755 frontend/service/studio_release_notifier/run.sh create mode 100644 tests/frontend/service/studio_release_notifier/test_app.py diff --git a/.github/workflows/publish-studio-release.yaml b/.github/workflows/publish-studio-release.yaml index e1dc01843..451731a11 100644 --- a/.github/workflows/publish-studio-release.yaml +++ b/.github/workflows/publish-studio-release.yaml @@ -18,7 +18,7 @@ on: workflow_dispatch: inputs: changelog: - description: User-facing Studio release summary + description: User-facing Studio updates, separated by semicolons required: true type: string thin_bundles: @@ -405,3 +405,60 @@ jobs: raise SystemExit(1) print(json.dumps(terminal["result"], ensure_ascii=False, indent=2)) PY + + notify: + name: Notify Studio release subscribers + needs: [release-context, publish] + if: >- + github.event_name == 'workflow_dispatch' && + github.repository == 'volcengine/veadk-python' && + github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: studio-release + steps: + - name: Send release card to Feishu groups + env: + WEBHOOK_URL: ${{ secrets.STUDIO_RELEASE_WEBHOOK_URL }} + WEBHOOK_KEY: ${{ secrets.STUDIO_RELEASE_WEBHOOK_KEY }} + RELEASE_VERSION: ${{ needs.release-context.outputs.version }} + RELEASE_CHANGELOG: ${{ inputs.changelog }} + run: | + python3 - <<'PYTHON' + import json + import os + import time + import urllib.error + import urllib.request + + url = os.environ["WEBHOOK_URL"] + key = os.environ["WEBHOOK_KEY"] + if not url.startswith("https://") or len(key) < 32: + raise SystemExit("Studio release Webhook secrets are missing or invalid") + version = os.environ["RELEASE_VERSION"] + payload = json.dumps({ + "version": version, + "date": f"{version[:4]}.{version[4:6]}.{version[6:8]}", + "changelog": os.environ["RELEASE_CHANGELOG"], + }).encode() + for attempt in range(3): + request = urllib.request.Request(url, data=payload, method="POST", headers={ + "Content-Type": "application/json", "X-API-Key": key, + }) + try: + with urllib.request.urlopen(request, timeout=190) as response: + result = json.load(response) + print(json.dumps(result, ensure_ascii=False)) + if result.get("ok") is not True: + raise SystemExit("Release notification was not delivered to every group") + break + except urllib.error.HTTPError as error: + print(f"Notification HTTP status: {error.code}") + if error.code < 500 and error.code != 429: + raise SystemExit("Check Webhook configuration and bot group membership") from None + except (OSError, urllib.error.URLError): + print("Notification request interrupted") + if attempt == 2: + raise SystemExit("Release succeeded, but notification failed after three attempts") + time.sleep(5 * (attempt + 1)) + PYTHON diff --git a/frontend/README.md b/frontend/README.md index 370283488..828de4af4 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -3,6 +3,13 @@ A React web UI for VeADK / Google ADK agents. It talks to the standard ADK API server that `veadk frontend` launches — no separate backend. +## Release notifications + +The release workflow sends one Feishu card after both cloud providers finish +publishing. A separate VeFaaS Webhook discovers the app bot’s group memberships +and persists delivery results to avoid duplicate notifications on retries. +See [deployment and operation](service/studio_release_notifier/README.md). + ## Features - **Sandbox updates** in System Information compare each Tool's current image diff --git a/frontend/service/studio_release_notifier/README.md b/frontend/service/studio_release_notifier/README.md new file mode 100644 index 000000000..4e8d9c5ae --- /dev/null +++ b/frontend/service/studio_release_notifier/README.md @@ -0,0 +1,69 @@ +# Studio release notifications + +An authenticated VeFaaS HTTPS endpoint broadcasts the approved release card to +all groups joined by the Feishu application bot. No group IDs are configured in +GitHub. Add the bot to a group to subscribe it to future releases. + +The `notify` job runs only after **both** Volcengine and BytePlus publishing jobs +succeed. It is separate from publication, so a notification failure does not +roll back a published version. The card contains the version, date and one list +of updates, with no environment label. Chinese and English semicolons and +newlines separate updates; empty entries are ignored. Markup in input is escaped. + +## Deployment + +Run from the repository root with the development dependencies installed: + +```sh +python -m frontend.service.studio_release_notifier.deploy +``` + +Provide `VOLCENGINE_ACCESS_KEY`, `VOLCENGINE_SECRET_KEY` and, for temporary +credentials, `VOLCENGINE_SESSION_TOKEN` through the local environment. +The runtime needs `FEISHU_APP_ID`, `FEISHU_APP_SECRET`, +`STUDIO_RELEASE_WEBHOOK_KEY` (at least 32 characters) and +`NOTIFIER_PREVIEW_USER_ID` (the operator's open ID for this application). +Never commit these values. + +The deployment creates or updates `veadk-studio-release-notifier` in cn-beijing, +with description `勿删:Studio 发版飞书群通知 Webhook`. It creates a dedicated +IAM role restricted to notification records under the existing `veadk-studio` +TOS bucket. Runtime cloud credentials come from VeFaaS IAM, not deployment AK/SK. +The function uses 1 vCPU and 2 GiB memory and a separate HTTPS gateway service. + +Configure these GitHub repository or `studio-release` environment secrets: + +| Name | Value | +| --- | --- | +| `STUDIO_RELEASE_WEBHOOK_URL` | The deployed HTTPS endpoint followed by `/release` | +| `STUDIO_RELEASE_WEBHOOK_KEY` | The same key configured in the function | + +The Feishu app needs bot messaging and joined-group read permissions. The bot +must be available to the intended users and added to each target group. + +## Endpoint contract + +All endpoints require the `X-API-Key` header: + +- `GET /readyz`: checks IAM credentials and access to notification storage +- `GET /groups`: lists groups joined by the bot +- `POST /preview`: sends a sample only to the configured operator +- `POST /release`: broadcasts a real release + +POST bodies contain `version`, `date` (`YYYY.MM.DD`) and `changelog` (a string +or array of strings). Preview cards explicitly identify sample content. +Empty or oversized changelogs are rejected. With no groups, `/release` returns +409 and does not mark the release delivered. + +A release's first request snapshots its recipient groups and content. Retries +must preserve that version, date and content; conflicting content returns 409. +New groups receive future releases, not historical retry notifications. +Each successful group's message ID is persisted in TOS. Transient failures +return 502, allowing the pipeline to retry only unfinished deliveries. +Concurrent retries use the same Feishu UUID for each version/group pair. + +Feishu's UUID deduplication lasts one hour. If a delivery is still ambiguous +near that limit, its status becomes `needs_review` and automatic retries stop +for that group to avoid duplicates. Check whether the group received the message +before repairing the corresponding TOS record. Do not delete delivery records +or change the version just to retry a failed request. diff --git a/frontend/service/studio_release_notifier/__init__.py b/frontend/service/studio_release_notifier/__init__.py new file mode 100644 index 000000000..7f463206f --- /dev/null +++ b/frontend/service/studio_release_notifier/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# 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. diff --git a/frontend/service/studio_release_notifier/app.py b/frontend/service/studio_release_notifier/app.py new file mode 100644 index 000000000..90ed9af69 --- /dev/null +++ b/frontend/service/studio_release_notifier/app.py @@ -0,0 +1,346 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# 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. + +"""Authenticated release notifications for every group joined by the app bot.""" + +from __future__ import annotations + +import hashlib +import hmac +import html +import json +import os +import re +import time +from pathlib import Path +from typing import Any + +import httpx +import tos +from fastapi import FastAPI, HTTPException, Request +from pydantic import BaseModel, ConfigDict, Field, field_validator +from starlette.concurrency import run_in_threadpool + + +class Release(BaseModel): + model_config = ConfigDict(extra="forbid") + version: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$") + changelog: list[str] | str + date: str = Field(pattern=r"^\d{4}\.\d{2}\.\d{2}$") + + @field_validator("changelog") + @classmethod + def validate_changelog(cls, value: list[str] | str) -> list[str]: + values = [value] if isinstance(value, str) else value + items = [ + part.strip() for item in values for part in re.split(r"[;;\n]+", item) + ] + items = [item for item in items if item] + if not items or len(items) > 50 or sum(map(len, items)) > 6000: + raise ValueError( + "Changelog must contain 1–50 items and at most 6000 characters" + ) + return items + + +def escape_markdown(value: str) -> str: + value = html.escape(value) + return re.sub(r"([*~`\[\]\\])", lambda match: f"&#{ord(match[0])};", value) + + +def build_card(release: Release, *, preview: bool = False) -> dict[str, Any]: + header: dict[str, Any] = { + "template": "blue", + "title": {"tag": "plain_text", "content": "Studio · Release Note"}, + "subtitle": { + "tag": "plain_text", + "content": f"{release.version} · {release.date}", + }, + } + elements: list[dict[str, Any]] = [ + { + "tag": "column_set", + "flex_mode": "none", + "columns": [ + { + "tag": "column", + "width": "weighted", + "weight": 1, + "background_style": "blue-50", + "padding": "12px", + "vertical_spacing": "8px", + "elements": [ + {"tag": "markdown", "content": "**本次更新**"}, + { + "tag": "markdown", + "content": "\n".join( + f"- {escape_markdown(item)}" + for item in release.changelog + ), + }, + ], + } + ], + } + ] + if preview: + header["text_tag_list"] = [ + { + "tag": "text_tag", + "text": {"tag": "plain_text", "content": "预览示例"}, + "color": "blue", + } + ] + elements.append( + { + "tag": "markdown", + "text_size": "notation", + "content": "版本与更新内容仅用于样式预览,不代表真实发版", + } + ) + return { + "schema": "2.0", + "config": { + "width_mode": "default", + "summary": { + "content": f"Studio {release.version} · {'样式预览' if preview else '本次更新'}" + }, + }, + "header": header, + "body": { + "direction": "vertical", + "padding": "12px", + "vertical_spacing": "12px", + "elements": elements, + }, + } + + +class Feishu: + def __init__(self) -> None: + self.client = httpx.Client( + base_url="https://open.feishu.cn/open-apis/", timeout=20 + ) + data = self.call( + "POST", + "auth/v3/tenant_access_token/internal", + json={ + "app_id": os.environ["FEISHU_APP_ID"], + "app_secret": os.environ["FEISHU_APP_SECRET"], + }, + ) + self.client.headers["Authorization"] = f"Bearer {data['tenant_access_token']}" + + def call(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: + response = self.client.request(method, path, **kwargs) + response.raise_for_status() + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"Feishu API error code {data.get('code')}") + return data + + def groups(self) -> list[str]: + groups: set[str] = set() + page = "" + seen: set[str] = set() + while True: + data = self.call( + "GET", "im/v1/chats", params={"page_size": 100, "page_token": page} + )["data"] + groups.update(item["chat_id"] for item in data.get("items", [])) + if not data.get("has_more"): + return sorted(groups) + page = data.get("page_token", "") + if not page or page in seen: + raise RuntimeError("Invalid Feishu pagination cursor") + seen.add(page) + + def send( + self, recipient: str, card: dict[str, Any], key: str, *, direct: bool = False + ) -> str: + data = self.call( + "POST", + "im/v1/messages", + params={"receive_id_type": "open_id" if direct else "chat_id"}, + json={ + "receive_id": recipient, + "msg_type": "interactive", + "content": json.dumps(card, ensure_ascii=False), + "uuid": key, + }, + ) + return data["data"]["message_id"] + + +class Store: + def __init__(self) -> None: + credentials = json.loads(Path("/var/run/secrets/iam/credential").read_text()) + region = os.environ["NOTIFIER_TOS_REGION"] + self.client = tos.TosClientV2( + credentials["access_key_id"], + credentials["secret_access_key"], + security_token=credentials["session_token"], + endpoint=f"tos-{region}.volces.com", + region=region, + ) + self.bucket = os.environ["NOTIFIER_TOS_BUCKET"] + + def get(self, key: str) -> dict[str, Any] | None: + try: + response = self.client.get_object( + self.bucket, f"veadk/studio/release-notifications/{key}.json" + ) + except tos.exceptions.TosServerError as error: + if error.status_code == 404: + return None + raise + return json.loads(response.read()) + + def put(self, key: str, value: dict[str, Any], *, create: bool = False) -> bool: + try: + self.client.put_object( + self.bucket, + f"veadk/studio/release-notifications/{key}.json", + content=json.dumps(value).encode(), + content_type="application/json", + forbid_overwrite=create, + ) + return True + except tos.exceptions.TosServerError as error: + if create and error.status_code == 409: + return False + raise + + +def broadcast(release: Release, bot: Feishu, store: Store) -> dict[str, Any]: + key = hashlib.sha256(release.version.encode()).hexdigest() + payload = release.model_dump() + manifest = store.get(key) + if manifest is None: + groups = bot.groups() + if not groups: + raise HTTPException(409, "Bot has not joined any groups") + store.put(key, {"release": payload, "groups": groups}, create=True) + manifest = store.get(key) + if manifest is None: + raise RuntimeError("Release record was not persisted") + if manifest["release"] != payload: + raise HTTPException(409, "Version already registered with different content") + results = [] + card = build_card(release) + for group in manifest["groups"]: + delivery = f"{key}/{group}" + try: + state = store.get(delivery) + if state and state.get("message_id"): + results.append({"chat_id": group, "status": "already_sent"}) + continue + if state is None: + store.put(delivery, {"started": time.time()}, create=True) + state = store.get(delivery) + if state is None: + raise RuntimeError("Delivery record was not persisted") + # Feishu deduplicates UUIDs for one hour. Ambiguous old attempts need review. + if time.time() - state["started"] > 3500: + results.append({"chat_id": group, "status": "needs_review"}) + continue + uuid = hashlib.sha256(delivery.encode()).hexdigest()[:40] + message = bot.send(group, card, uuid) + store.put(delivery, {**state, "message_id": message}) + results.append({"chat_id": group, "status": "sent"}) + except (httpx.HTTPError, RuntimeError, tos.exceptions.TosError): + results.append({"chat_id": group, "status": "failed"}) + return { + "ok": all(item["status"] in {"sent", "already_sent"} for item in results), + "version": release.version, + "deliveries": results, + } + + +app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) + + +@app.middleware("http") +async def authenticate(request: Request, call_next: Any) -> Any: + from fastapi.responses import JSONResponse + + expected = os.environ.get("STUDIO_RELEASE_WEBHOOK_KEY", "") + supplied = request.headers.get("x-api-key", "") + if len(expected) < 32 or not hmac.compare_digest( + expected.encode(), supplied.encode() + ): + return JSONResponse({"detail": "Unauthorized"}, status_code=401) + return await call_next(request) + + +@app.get("/readyz") +def ready() -> dict[str, bool]: + Store().get("health") + return {"ok": True} + + +@app.get("/groups") +def groups() -> dict[str, Any]: + bot = Feishu() + try: + return {"groups": bot.groups()} + finally: + bot.client.close() + + +def deliver(release: Release, preview: bool) -> dict[str, Any]: + bot = Feishu() + try: + if preview: + recipient = os.environ.get("NOTIFIER_PREVIEW_USER_ID", "") + if not recipient: + raise HTTPException(409, "Preview recipient is not configured") + uuid = hashlib.sha256( + f"preview/{release.model_dump_json()}".encode() + ).hexdigest()[:40] + return { + "ok": True, + "message_id": bot.send( + recipient, build_card(release, preview=True), uuid, direct=True + ), + } + return broadcast(release, bot, Store()) + finally: + bot.client.close() + + +@app.post("/release") +@app.post("/preview") +async def notify(request: Request) -> Any: + from fastapi.responses import JSONResponse + from pydantic import ValidationError + + body = bytearray() + async for chunk in request.stream(): + body.extend(chunk) + if len(body) > 32_000: + raise HTTPException(413, "Request is too large") + try: + release = Release.model_validate_json(body) + except ValidationError: + raise HTTPException(422, "Invalid version, date or changelog") from None + try: + result = await run_in_threadpool( + deliver, release, request.url.path == "/preview" + ) + except (httpx.HTTPError, RuntimeError, tos.exceptions.TosError): + raise HTTPException( + 502, "Notification dependency failed; retry with the same payload" + ) from None + return JSONResponse(result, status_code=200 if result["ok"] else 502) diff --git a/frontend/service/studio_release_notifier/deploy.py b/frontend/service/studio_release_notifier/deploy.py new file mode 100644 index 000000000..942a43144 --- /dev/null +++ b/frontend/service/studio_release_notifier/deploy.py @@ -0,0 +1,633 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# 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. + +"""Provision the release notifier with AK/SK, a scoped IAM role and HTTPS.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import shutil +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Any +from collections.abc import Callable + +from veadk.cloud.cloud_agent_engine import CloudAgentEngine +from veadk.utils.cloud_provider import CloudProvider, iam_openapi_host + +_FUNCTION_NAME = "veadk-studio-release-notifier" +_GATEWAY_NAME = "test-api-gateway" +_GATEWAY_SERVICE_NAME = _FUNCTION_NAME +_GATEWAY_UPSTREAM_NAME = _FUNCTION_NAME +_GATEWAY_ROUTE_NAME = _FUNCTION_NAME +_GATEWAY_TIMEOUT_MILLISECONDS = 180000 +_RESOURCE_NOTE = "勿删:Studio 发版飞书群通知 Webhook" +_ROLE_NAME = "VeADKStudioReleaseNotifierRole" +_POLICY_NAME = "VeADKStudioReleaseNotifierPolicy" +_TRUST_POLICY = { + "Statement": [ + { + "Effect": "Allow", + "Action": ["sts:AssumeRole"], + "Principal": {"Service": ["vefaas"]}, + } + ] +} +_TOS_POLICY = { + "Statement": [ + { + "Effect": "Allow", + "Action": ["tos:GetObject", "tos:PutObject"], + "Resource": ["trn:tos:::veadk-studio/veadk/studio/release-notifications/*"], + } + ] +} + + +def _result(response: dict[str, Any]) -> dict[str, Any]: + metadata = response.get("ResponseMetadata", {}) or {} + if metadata.get("Error"): + error = metadata["Error"] + raise RuntimeError(error.get("Message") or str(error)) + return response.get("Result", {}) or {} + + +def _role_trn(result: dict[str, Any]) -> str: + role = result.get("Role") or result + return str(role.get("Trn") or role.get("trn") or "") + + +def _ensure_runtime_role( + access_key: str, + secret_key: str, + *, + provider: CloudProvider, + session_token: str = "", +) -> str: + """Create or refresh the minimal VeFaaS role used for TOS publishing.""" + from volcengine.iam.IamService import IamService + + iam = IamService() + iam.set_ak(access_key) + iam.set_sk(secret_key) + iam.set_host(iam_openapi_host(provider)) + if provider == "byteplus": + iam.set_scheme("https") + if session_token: + iam.set_session_token(session_token) + policy_document = json.dumps(_TOS_POLICY) + try: + _result( + iam.update_policy( + { + "PolicyName": _POLICY_NAME, + "NewPolicyDocument": policy_document, + } + ) + ) + except Exception as update_error: # noqa: BLE001 - SDK has no common error type + try: + _result( + iam.create_policy( + { + "PolicyName": _POLICY_NAME, + "PolicyDocument": policy_document, + "Description": _RESOURCE_NOTE, + } + ) + ) + except Exception as create_error: # noqa: BLE001 - preserve both SDK errors + raise RuntimeError( + f"Could not create or update IAM policy: {create_error}" + ) from update_error + + try: + role_result = _result(iam.get_role({"RoleName": _ROLE_NAME})) + except Exception: # noqa: BLE001 - absent roles are reported as SDK exceptions + role_result = _result( + iam.create_role( + { + "RoleName": _ROLE_NAME, + "TrustPolicyDocument": json.dumps(_TRUST_POLICY), + "Description": _RESOURCE_NOTE, + } + ) + ) + trn = _role_trn(role_result) + if not trn: + trn = _role_trn(_result(iam.get_role({"RoleName": _ROLE_NAME}))) + if not trn: + raise RuntimeError("Could not resolve release server IAM role TRN.") + + attached = _result(iam.list_attached_role_policies({"RoleName": _ROLE_NAME})).get( + "AttachedPolicyMetadata", [] + ) + if not any(item.get("PolicyName") == _POLICY_NAME for item in attached): + _result( + iam.attach_role_policy( + { + "RoleName": _ROLE_NAME, + "PolicyName": _POLICY_NAME, + "PolicyType": "Custom", + } + ) + ) + return trn + + +def _find_named(items: list[Any], name: str) -> Any | None: + matches = [item for item in items if getattr(item, "name", None) == name] + if len(matches) > 1: + raise RuntimeError(f"Multiple cloud resources are named {name}.") + return matches[0] if matches else None + + +def _find_function(service: Any) -> Any | None: + from volcenginesdkvefaas import ListFunctionsRequest + + page_number = 1 + page_size = 100 + functions: list[Any] = [] + while True: + response = service.client.list_functions( + ListFunctionsRequest(page_number=page_number, page_size=page_size) + ) + functions.extend(list(getattr(response, "items", []) or [])) + total = int(getattr(response, "total", 0) or 0) + if page_number * page_size >= total: + break + page_number += 1 + return _find_named(functions, _FUNCTION_NAME) + + +def _retry_code_upload(operation: Callable[[], None]) -> None: + """Retry transient presigned-URL failures without masking other errors.""" + for attempt in range(1, 4): + try: + operation() + return + except ValueError as error: + if "Function code upload request failed" not in str(error) or attempt == 3: + raise + time.sleep(2**attempt) + + +def _release_function(service: Any, function_id: str) -> None: + from volcenginesdkvefaas import GetReleaseStatusRequest, ReleaseRequest + + service.client.release(ReleaseRequest(function_id=function_id, revision_number=0)) + for _ in range(120): + response = service.client.get_release_status( + GetReleaseStatusRequest(function_id=function_id) + ) + state = str(getattr(response, "status", "") or "").lower() + if "succ" in state or state == "done": + return + if "fail" in state or "error" in state: + raise RuntimeError(f"Function release failed: {state}") + time.sleep(5) + raise RuntimeError("Function release did not finish in 10 minutes.") + + +def _https_endpoint(gateway_service: Any) -> str: + for domain in getattr(gateway_service, "domains", []) or []: + value = ( + domain.get("domain", "") + if isinstance(domain, dict) + else getattr(domain, "domain", "") + ) + if str(value).startswith("https://"): + return str(value).rstrip("/") + return "" + + +def _find_reusable_serverless_gateway(gateways: list[Any]) -> Any | None: + """Return an existing running serverless gateway when quota blocks a new one.""" + return next( + ( + gateway + for gateway in gateways + if getattr(gateway, "type", None) == "serverless" + and (getattr(gateway, "status", None) or getattr(gateway, "message", None)) + == "Running" + ), + None, + ) + + +def _create_serverless_gateway(apig: Any) -> str: + from volcenginesdkapig import ( + CreateGatewayRequest, + ListGatewaysRequest, + ResourceSpecForCreateGatewayInput, + ) + + response = apig.apig_client.create_gateway( + CreateGatewayRequest( + comments=_RESOURCE_NOTE, + name=_GATEWAY_NAME, + region=apig.region, + type="serverless", + resource_spec=ResourceSpecForCreateGatewayInput( + replicas=2, + instance_spec_code="1c2g", + clb_spec_code="small_1", + public_network_billing_type="traffic", + network_type={ + "EnablePublicNetwork": True, + "EnablePrivateNetwork": False, + }, + ), + ), + async_req=True, + ).get() + gateway_id = str(response.id) + for _ in range(120): + gateways = apig.apig_client.list_gateways( + ListGatewaysRequest(page_number=1, page_size=100), + async_req=True, + ).get() + gateway = _find_named( + list(getattr(gateways, "items", []) or []), + _GATEWAY_NAME, + ) + if gateway is not None: + state = getattr(gateway, "status", None) or getattr( + gateway, "message", None + ) + if state == "Running": + return gateway_id + if state in {"Failed", "Error"}: + raise RuntimeError(f"Gateway creation failed: {state}") + time.sleep(5) + raise RuntimeError("API gateway did not become ready in 10 minutes.") + + +def _create_gateway_service(apig: Any, gateway_id: str) -> str: + from volcenginesdkapig import ( + AuthSpecForCreateGatewayServiceInput, + CreateGatewayServiceRequest, + ) + + response = apig.apig_client.create_gateway_service( + CreateGatewayServiceRequest( + auth_spec=AuthSpecForCreateGatewayServiceInput(enable=False), + comments=_RESOURCE_NOTE, + gateway_id=gateway_id, + protocol=["HTTP", "HTTPS"], + service_name=_GATEWAY_SERVICE_NAME, + ), + async_req=True, + ).get() + return str(response.id) + + +def _create_gateway_upstream(apig: Any, function_id: str, gateway_id: str) -> str: + from volcenginesdkapig import ( + CreateUpstreamRequest, + UpstreamSpecForCreateUpstreamInput, + VeFaasForCreateUpstreamInput, + ) + + response = apig.apig_client.create_upstream( + CreateUpstreamRequest( + comments=_RESOURCE_NOTE, + gateway_id=gateway_id, + name=_GATEWAY_UPSTREAM_NAME, + source_type="VeFaas", + upstream_spec=UpstreamSpecForCreateUpstreamInput( + ve_faas=VeFaasForCreateUpstreamInput(function_id=function_id) + ), + ), + async_req=True, + ).get() + return str(response.id) + + +def _ensure_gateway_binding(service: Any, function_id: str) -> str: + """Expose one Function through a service on the fixed serverless gateway.""" + from volcenginesdkapig import ( + ListGatewayServicesRequest, + ListGatewaysRequest, + ListUpstreamsRequest, + ) + from volcenginesdkapig20221112 import ( + AdvancedSettingForUpdateRouteInput, + ListRoutesRequest, + MatchRuleForUpdateRouteInput, + PathForUpdateRouteInput, + TimeoutSettingForUpdateRouteInput, + UpdateRouteRequest, + UpstreamListForUpdateRouteInput, + ) + + apig = service.apig_client + gateway_response = apig.apig_client.list_gateways( + ListGatewaysRequest(page_number=1, page_size=100), async_req=True + ).get() + gateways = list(getattr(gateway_response, "items", []) or []) + gateway = _find_named(gateways, _GATEWAY_NAME) + if gateway is None: + gateway = _find_reusable_serverless_gateway(gateways) + if gateway is None: + gateway_id = _create_serverless_gateway(apig) + else: + gateway_id = str(gateway.id) + else: + if getattr(gateway, "type", None) != "serverless": + raise RuntimeError(f"Gateway {_GATEWAY_NAME} is not serverless.") + gateway_state = getattr(gateway, "status", None) or getattr( + gateway, "message", None + ) + if gateway_state != "Running": + raise RuntimeError(f"Gateway {_GATEWAY_NAME} is not running.") + gateway_id = str(gateway.id) + + service_response = apig.apig_client.list_gateway_services( + ListGatewayServicesRequest( + gateway_id=gateway_id, + page_number=1, + page_size=100, + ), + async_req=True, + ).get() + gateway_service = _find_named( + list(getattr(service_response, "items", []) or []), + _GATEWAY_SERVICE_NAME, + ) + if gateway_service is None: + service_id = _create_gateway_service(apig, gateway_id) + else: + service_id = str(gateway_service.id) + + upstream_response = apig.apig_client.list_upstreams( + ListUpstreamsRequest( + gateway_id=gateway_id, + page_number=1, + page_size=100, + ), + async_req=True, + ).get() + upstream = _find_named( + list(getattr(upstream_response, "items", []) or []), + _GATEWAY_UPSTREAM_NAME, + ) + if upstream is None: + upstream_id = _create_gateway_upstream(apig, function_id, gateway_id) + else: + upstream_payload = upstream.to_dict() + if function_id not in json.dumps(upstream_payload): + raise RuntimeError( + f"Upstream {_GATEWAY_UPSTREAM_NAME} targets another Function." + ) + upstream_id = str(upstream.id) + + route_response = apig.apig_20221112_client.list_routes( + ListRoutesRequest( + service_id=service_id, + page_number=1, + page_size=100, + ), + async_req=True, + ).get() + route = _find_named( + list(getattr(route_response, "items", []) or []), + _GATEWAY_ROUTE_NAME, + ) + if route is None: + route_id = apig.create_gateway_service_routes( + service_id, + upstream_id, + _GATEWAY_ROUTE_NAME, + { + "match_content": "/", + "match_type": "Prefix", + "match_method": ["GET", "POST"], + }, + ) + else: + route_payload = route.to_dict() + if upstream_id not in json.dumps(route_payload): + raise RuntimeError(f"Route {_GATEWAY_ROUTE_NAME} targets another upstream.") + route_id = str(route.id) + apig.apig_20221112_client.update_route( + UpdateRouteRequest( + id=route_id, + name=_GATEWAY_ROUTE_NAME, + enable=True, + priority=1, + match_rule=MatchRuleForUpdateRouteInput( + method=["GET", "POST"], + path=PathForUpdateRouteInput( + match_content="/", + match_type="Prefix", + ), + ), + upstream_list=[ + UpstreamListForUpdateRouteInput( + upstream_id=upstream_id, + weight=1, + ) + ], + advanced_setting=AdvancedSettingForUpdateRouteInput( + timeout_setting=TimeoutSettingForUpdateRouteInput( + enable=True, + timeout=_GATEWAY_TIMEOUT_MILLISECONDS, + ) + ), + ), + async_req=True, + ).get() + + for _ in range(60): + service_response = apig.apig_client.list_gateway_services( + ListGatewayServicesRequest( + gateway_id=gateway_id, + page_number=1, + page_size=100, + ), + async_req=True, + ).get() + gateway_service = _find_named( + list(getattr(service_response, "items", []) or []), + _GATEWAY_SERVICE_NAME, + ) + if gateway_service is not None: + endpoint = _https_endpoint(gateway_service) + state = getattr(gateway_service, "status", None) or getattr( + gateway_service, "message", None + ) + if state == "Running" and endpoint: + return endpoint + time.sleep(5) + raise RuntimeError("API gateway service did not become ready in 5 minutes.") + + +def stage(source: Path, destination: Path) -> None: + target = destination / "frontend/service/studio_release_notifier" + target.mkdir(parents=True) + for relative in ("frontend/__init__.py", "frontend/service/__init__.py"): + shutil.copy2(source / relative, destination / relative) + for name in ("app.py", "__init__.py"): + shutil.copy2( + source / "frontend/service/studio_release_notifier" / name, target / name + ) + shutil.copy2( + source / "frontend/service/studio_release_notifier/run.sh", destination + ) + packages = destination / "site-packages" + subprocess.run( + [ + "uv", + "pip", + "install", + "--target", + str(packages), + "--python-version", + "3.12", + "--python-platform", + "x86_64-manylinux2014", + "--link-mode", + "copy", + "fastapi>=0.115,<1", + "httpx>=0.27,<1", + "uvicorn>=0.34,<1", + "requests>=2.19.1,<3", + "six", + "pytz", + "Deprecated>=1.2.13,<2", + ], + check=True, + ) + for name in ("tos", "crcmod"): + module = importlib.util.find_spec(name) + if module is None or module.submodule_search_locations is None: + raise RuntimeError(f"Missing packaging dependency: {name}") + shutil.copytree( + Path(next(iter(module.submodule_search_locations))), + packages / name, + ignore=shutil.ignore_patterns("*.so", "*.dylib", "__pycache__"), + ) + + +def main() -> None: + from volcenginesdkvefaas import ( + CreateFunctionRequest, + EnvForCreateFunctionInput, + UpdateFunctionRequest, + TagForCreateFunctionInput, + ) + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-root", type=Path, default=Path.cwd()) + args = parser.parse_args() + environment = { + name: os.environ[name] + for name in ( + "FEISHU_APP_ID", + "FEISHU_APP_SECRET", + "STUDIO_RELEASE_WEBHOOK_KEY", + "NOTIFIER_PREVIEW_USER_ID", + ) + } + environment.update( + NOTIFIER_TOS_BUCKET="veadk-studio", NOTIFIER_TOS_REGION="cn-beijing" + ) + if len(environment["STUDIO_RELEASE_WEBHOOK_KEY"]) < 32: + raise ValueError("Webhook key must contain at least 32 characters") + access_key, secret_key = ( + os.environ["VOLCENGINE_ACCESS_KEY"], + os.environ["VOLCENGINE_SECRET_KEY"], + ) + session = os.environ.get("VOLCENGINE_SESSION_TOKEN", "") + role = _ensure_runtime_role( + access_key, secret_key, provider="volcengine", session_token=session + ) + engine = CloudAgentEngine( + volcengine_access_key=access_key, + volcengine_secret_key=secret_key, + volcengine_session_token=session, + region="cn-beijing", + provider="volcengine", + ) + service = engine._vefaas_service + with tempfile.TemporaryDirectory(prefix="studio-release-notifier-") as directory: + root = Path(directory) + stage(args.source_root.resolve(), root) + function = _find_function(service) + if function is None: + result: Any = service.client.create_function( + CreateFunctionRequest( + name=_FUNCTION_NAME, + description=_RESOURCE_NOTE, + runtime="native-python3.12/v1", + command="./run.sh", + port=8000, + cpu_milli=1000, + memory_mb=2048, + max_concurrency=10, + request_timeout=180, + initializer_sec=60, + role=role, + project_name="default", + tags=[TagForCreateFunctionInput(key="note", value="勿删")], + envs=[ + EnvForCreateFunctionInput(key=k, value=v) + for k, v in environment.items() + ], + ) + ) + function_id = str(result.id) + print( + json.dumps({"functionId": function_id, "stage": "upload"}), flush=True + ) + _retry_code_upload( + lambda: service._upload_and_mount_code(function_id, str(root)) + ) + else: + function_id = str(function.id) + service.client.update_function( + UpdateFunctionRequest( + id=function_id, description=_RESOURCE_NOTE, role=role + ) + ) + _retry_code_upload( + lambda: service._replace_application_code_bundle( + function_id=function_id, + path=str(root), + environment_overrides=environment, + ) + ) + _release_function(service, function_id) + endpoint = _ensure_gateway_binding(service, function_id) + print( + json.dumps( + { + "functionId": function_id, + "endpoint": endpoint, + "description": _RESOURCE_NOTE, + }, + ensure_ascii=False, + ), + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/frontend/service/studio_release_notifier/requirements.txt b/frontend/service/studio_release_notifier/requirements.txt new file mode 100644 index 000000000..a02a7d520 --- /dev/null +++ b/frontend/service/studio_release_notifier/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115,<1 +httpx>=0.27,<1 +uvicorn>=0.34,<1 +tos>=2.8,<3 diff --git a/frontend/service/studio_release_notifier/run.sh b/frontend/service/studio_release_notifier/run.sh new file mode 100755 index 000000000..95d53830c --- /dev/null +++ b/frontend/service/studio_release_notifier/run.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")" +export PYTHONPATH="$PWD:$PWD/site-packages${PYTHONPATH:+:$PYTHONPATH}" +exec python3 -m uvicorn frontend.service.studio_release_notifier.app:app --host 0.0.0.0 --port "${_FAAS_RUNTIME_PORT:-8000}" diff --git a/tests/frontend/service/studio_release_notifier/test_app.py b/tests/frontend/service/studio_release_notifier/test_app.py new file mode 100644 index 000000000..72b70b3c4 --- /dev/null +++ b/tests/frontend/service/studio_release_notifier/test_app.py @@ -0,0 +1,139 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# 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. + +import copy +import time + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from frontend.service.studio_release_notifier.app import ( + Release, + app, + broadcast, + build_card, +) + + +class MemoryStore: + def __init__(self): + self.values = {} + + def get(self, key): + return copy.deepcopy(self.values.get(key)) + + def put(self, key, value, *, create=False): + if create and key in self.values: + return False + self.values[key] = copy.deepcopy(value) + return True + + +class Bot: + def __init__(self): + self.sent = [] + self.fail = {"oc_b"} + + def groups(self): + return ["oc_a", "oc_b"] + + def send(self, group, card, uuid): + if group in self.fail: + raise RuntimeError("Transient failure") + self.sent.append((group, uuid)) + return "om_" + group + + +def release(text="新增功能;优化体验;修复问题;; "): + return Release(version="20260909160000", date="2026.09.09", changelog=text) + + +def test_split_and_escape_card(): + value = release( + "新增功能;优化体验; 全体;[链接](https://example.com)" + ) + assert len(value.changelog) == 4 + card = build_card(value) + content = card["body"]["elements"][0]["columns"][0]["elements"][1]["content"] + assert len(content.splitlines()) == 4 + assert " Date: Wed, 9 Sep 2026 20:22:30 +0800 Subject: [PATCH 2/4] test(studio): avoid release notifier test module name collision --- .../{test_app.py => test_release_notifier.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/frontend/service/studio_release_notifier/{test_app.py => test_release_notifier.py} (100%) diff --git a/tests/frontend/service/studio_release_notifier/test_app.py b/tests/frontend/service/studio_release_notifier/test_release_notifier.py similarity index 100% rename from tests/frontend/service/studio_release_notifier/test_app.py rename to tests/frontend/service/studio_release_notifier/test_release_notifier.py From c02f727384081e747812a38011f6ee1c4d761e70 Mon Sep 17 00:00:00 2001 From: evanlowe <62918515+evanlowe@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:43:02 +0800 Subject: [PATCH 3/4] test(studio): exercise release workflow notification failures and retries --- .../test_notification_workflow.py | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 tests/frontend/service/studio_release_notifier/test_notification_workflow.py diff --git a/tests/frontend/service/studio_release_notifier/test_notification_workflow.py b/tests/frontend/service/studio_release_notifier/test_notification_workflow.py new file mode 100644 index 000000000..1a058b8b5 --- /dev/null +++ b/tests/frontend/service/studio_release_notifier/test_notification_workflow.py @@ -0,0 +1,185 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# 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. + +"""Run the actual workflow notification script against a mocked HTTP boundary.""" + +import copy +import io +import json +import time +import urllib.error +import urllib.request +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml +from fastapi.testclient import TestClient + +from frontend.service.studio_release_notifier import app as notifier + +WORKFLOW = ( + Path(__file__).resolve().parents[4] + / ".github/workflows/publish-studio-release.yaml" +) + + +@pytest.fixture +def workflow_script(monkeypatch): + workflow = yaml.safe_load(WORKFLOW.read_text()) + script = workflow["jobs"]["notify"]["steps"][0]["run"] + source = script.split("<<'PYTHON'\n", 1)[1].rsplit("PYTHON", 1)[0] + for name, value in { + "WEBHOOK_URL": "https://notifier.example/release", + "WEBHOOK_KEY": "test-key-" * 5, + "STUDIO_RELEASE_WEBHOOK_KEY": "test-key-" * 5, + "RELEASE_VERSION": "20260909210000", + "RELEASE_CHANGELOG": "新增功能;优化体验;修复问题", + }.items(): + monkeypatch.setenv(name, value) + return compile(source, str(WORKFLOW) + ":notify", "exec") + + +def test_notification_is_downstream_of_both_publish_jobs(): + jobs = yaml.safe_load(WORKFLOW.read_text())["jobs"] + assert jobs["notify"]["needs"] == ["release-context", "publish"] + assert "notify" not in jobs["publish"]["needs"] + assert { + entry["provider"] for entry in jobs["publish"]["strategy"]["matrix"]["include"] + } == {"volcengine", "byteplus"} + # GitHub applies success() by default unless a status function overrides it. + assert not any( + token in jobs["notify"]["if"] for token in ("always(", "failure(", "cancelled(") + ) + assert not jobs["notify"].get("continue-on-error", False) + + +@pytest.mark.parametrize( + ("outcomes", "calls", "delays", "success"), + [ + ([200], 1, [], True), + ([502, 200], 2, [5], True), + ([502, 502, 502], 3, [5, 10], False), + ([429, 200], 2, [5], True), + ([401], 1, [], False), + ([409], 1, [], False), + (["timeout", 200], 2, [5], True), + ], +) +def test_actual_workflow_retry_policy( + monkeypatch, workflow_script, outcomes, calls, delays, success +): + requests, sleeps = [], [] + + def urlopen(request, timeout): + assert timeout == 190 + assert request.full_url == "https://notifier.example/release" + assert request.get_header("X-api-key") == "test-key-" * 5 + requests.append(json.loads(request.data)) + outcome = outcomes[len(requests) - 1] + if outcome == "timeout": + raise urllib.error.URLError("simulated timeout") + if outcome != 200: + raise urllib.error.HTTPError( + request.full_url, outcome, "simulated failure", {}, None + ) + return io.BytesIO(b'{"ok":true}') + + monkeypatch.setattr(urllib.request, "urlopen", urlopen) + monkeypatch.setattr(time, "sleep", sleeps.append) + if success: + exec(workflow_script, {"__name__": "__main__"}) + else: + with pytest.raises(SystemExit): + exec(workflow_script, {"__name__": "__main__"}) + assert len(requests) == calls + assert sleeps == delays + assert all(payload == requests[0] for payload in requests) + assert requests[0] == { + "version": "20260909210000", + "date": "2026.09.09", + "changelog": "新增功能;优化体验;修复问题", + } + + +@pytest.mark.parametrize("permanent_failure", [False, True]) +def test_workflow_through_real_http_handler_and_broadcast( + monkeypatch, workflow_script, permanent_failure +): + records, attempts, sent, requests = {}, [], [], [] + + class Store: + def get(self, key): + return copy.deepcopy(records.get(key)) + + def put(self, key, value, *, create=False): + if create and key in records: + return False + records[key] = copy.deepcopy(value) + return True + + class Bot: + client = SimpleNamespace(close=lambda: None) + + def groups(self): + return ["oc_a", "oc_b"] + + def send(self, group, card, uuid): + attempts.append((group, uuid)) + count = sum(item[0] == "oc_b" for item in attempts) + if group == "oc_b" and (permanent_failure or count == 1): + raise RuntimeError("simulated group delivery failure") + content = card["body"]["elements"][0]["columns"][0]["elements"][1][ + "content" + ] + assert content == "- 新增功能\n- 优化体验\n- 修复问题" + assert "适用环境" not in str(card) + sent.append(group) + return "om_" + group + + monkeypatch.setattr(notifier, "Store", Store) + monkeypatch.setattr(notifier, "Feishu", Bot) + monkeypatch.setattr(time, "sleep", lambda _: None) + with TestClient(notifier.app) as client: + + def urlopen(request, timeout): + requests.append(request.data) + response = client.post( + "/release", content=request.data, headers=dict(request.header_items()) + ) + if response.status_code != 200: + raise urllib.error.HTTPError( + request.full_url, + response.status_code, + "mock gateway", + {}, + io.BytesIO(response.content), + ) + return io.BytesIO(response.content) + + monkeypatch.setattr(urllib.request, "urlopen", urlopen) + if permanent_failure: + with pytest.raises( + SystemExit, match="Release succeeded, but notification failed" + ): + exec(workflow_script, {"__name__": "__main__"}) + else: + exec(workflow_script, {"__name__": "__main__"}) + + assert len(requests) == (3 if permanent_failure else 2) + assert sent == (["oc_a"] if permanent_failure else ["oc_a", "oc_b"]) + assert len({uuid for group, uuid in attempts if group == "oc_b"}) == 1 + assert sum(bool(record.get("message_id")) for record in records.values()) == len( + sent + ) From 5946a0b4df0eee7c99eca90570112ace186f1649 Mon Sep 17 00:00:00 2001 From: evanlowe <62918515+evanlowe@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:56:35 +0800 Subject: [PATCH 4/4] fix(studio): name release cards AgentKit Studio --- frontend/service/studio_release_notifier/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/service/studio_release_notifier/app.py b/frontend/service/studio_release_notifier/app.py index 90ed9af69..0268100c4 100644 --- a/frontend/service/studio_release_notifier/app.py +++ b/frontend/service/studio_release_notifier/app.py @@ -62,7 +62,7 @@ def escape_markdown(value: str) -> str: def build_card(release: Release, *, preview: bool = False) -> dict[str, Any]: header: dict[str, Any] = { "template": "blue", - "title": {"tag": "plain_text", "content": "Studio · Release Note"}, + "title": {"tag": "plain_text", "content": "AgentKit Studio 新版本发布"}, "subtitle": { "tag": "plain_text", "content": f"{release.version} · {release.date}",