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}",