Skip to content

Commit 8db33d3

Browse files
authored
feat: harden Studio release and self-update flow (#747)
* feat: harden Studio release updates * fix: parse VeFaaS timestamps on Python 3.10
1 parent 1abbe65 commit 8db33d3

25 files changed

Lines changed: 2276 additions & 990 deletions

.github/workflows/publish-studio-release.yaml

Lines changed: 117 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -67,41 +67,12 @@ jobs:
6767
- name: Set up uv
6868
uses: astral-sh/setup-uv@v6
6969

70-
- name: Test Studio release backend
71-
run: |
72-
uv run --group dev pytest -q \
73-
tests/test_studio_release_server.py \
74-
tests/cli/test_studio_release.py
75-
76-
- name: Stage and test release source
77-
id: source
78-
run: |
79-
set -euo pipefail
80-
archive="$RUNNER_TEMP/studio-release-source.tar.gz"
81-
source_parent="$RUNNER_TEMP/studio-release-source"
82-
source_root="$source_parent/veadk-python-${GITHUB_SHA}"
83-
prepared_root="$source_root/.studio-release"
84-
mkdir -p "$source_root" "$prepared_root/frontend"
85-
git archive "$GITHUB_SHA" -- \
86-
pyproject.toml README.md LICENSE frontend veadk \
87-
':(exclude)veadk/webui' | tar -x -C "$source_root"
88-
npm ci --prefix frontend
89-
npm test --prefix frontend
90-
npm run build --prefix frontend -- \
91-
--outDir "$prepared_root/frontend"
92-
python -m veadk.cli.studio_dependencies \
93-
--output-dir "$prepared_root/wheels" \
94-
--manifest "$prepared_root/dependencies.json"
95-
tar -C "$source_parent" \
96-
--exclude="veadk-python-${GITHUB_SHA}/.studio-release/wheels" \
97-
-czf "$archive" \
98-
"veadk-python-${GITHUB_SHA}"
99-
echo "archive=$archive" >> "$GITHUB_OUTPUT"
100-
echo "source_root=$source_root" >> "$GITHUB_OUTPUT"
101-
10270
- name: Build and validate Studio bundle
10371
env:
104-
SOURCE_ROOT: ${{ steps.source.outputs.source_root }}
72+
PIP_INDEX_URL: https://mirrors.aliyun.com/pypi/simple/
73+
UV_DEFAULT_INDEX: https://mirrors.aliyun.com/pypi/simple/
74+
npm_config_registry: https://registry.npmmirror.com
75+
npm_config_replace_registry_host: always
10576
run: |
10677
set -euo pipefail
10778
output_dir="$RUNNER_TEMP/studio-release-output"
@@ -114,16 +85,14 @@ jobs:
11485
11586
from veadk.cli.studio_release import build_studio_release
11687
117-
source_root = Path(os.environ["SOURCE_ROOT"])
88+
source_root = Path(os.environ["GITHUB_WORKSPACE"])
11889
output_dir = Path(os.environ["output_dir"])
11990
bundle, manifest = build_studio_release(
12091
source_root=source_root,
12192
output_dir=output_dir,
12293
version=os.environ["version"],
12394
git_sha=os.environ["GITHUB_SHA"],
12495
changelog=("Pull request release validation",),
125-
frontend_assets=source_root / ".studio-release" / "frontend",
126-
dependency_wheels=source_root / ".studio-release" / "wheels",
12796
)
12897
print(
12998
json.dumps(
@@ -143,15 +112,6 @@ jobs:
143112
test "${#manifests[@]}" -eq 1
144113
unzip -t "${bundles[0]}"
145114
146-
- name: Preserve staged source for publishing
147-
if: github.event_name == 'workflow_dispatch'
148-
uses: actions/upload-artifact@v4
149-
with:
150-
name: studio-release-source-${{ github.sha }}
151-
path: ${{ steps.source.outputs.archive }}
152-
compression-level: 0
153-
retention-days: 1
154-
155115
publish:
156116
if: >-
157117
github.event_name == 'workflow_dispatch' &&
@@ -166,12 +126,6 @@ jobs:
166126
RELEASE_SERVER_API_KEY: ${{ secrets.STUDIO_RELEASE_SERVER_API_KEY }}
167127

168128
steps:
169-
- name: Download staged release source
170-
uses: actions/download-artifact@v4
171-
with:
172-
name: studio-release-source-${{ github.sha }}
173-
path: ${{ runner.temp }}/studio-release-source
174-
175129
- name: Validate release server configuration
176130
run: |
177131
set -euo pipefail
@@ -190,119 +144,124 @@ jobs:
190144
fi
191145
echo "RELEASE_SERVER_URL=$release_server_url" >> "$GITHUB_ENV"
192146
193-
- name: Resolve release changelog
194-
id: changelog
147+
- name: Request Studio release
195148
env:
196-
MANUAL_CHANGELOG: ${{ inputs.changelog }}
149+
STUDIO_CHANGELOG: ${{ inputs.changelog }}
197150
run: |
198151
set -euo pipefail
199-
changelog="$MANUAL_CHANGELOG"
200-
test -n "$changelog" || {
152+
test -n "$STUDIO_CHANGELOG" || {
201153
echo "Studio release changelog is empty." >&2
202154
exit 1
203155
}
204-
{
205-
echo "value<<STUDIO_CHANGELOG"
206-
echo "$changelog"
207-
echo "STUDIO_CHANGELOG"
208-
} >> "$GITHUB_OUTPUT"
156+
python - <<'PY'
157+
import http.client
158+
import json
159+
import os
160+
import sys
161+
import time
162+
import urllib.error
163+
import urllib.request
209164
210-
- name: Upload staged release source
211-
id: source
212-
run: |
213-
set -euo pipefail
214-
job_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
215-
archive="$RUNNER_TEMP/studio-release-source/studio-release-source.tar.gz"
216-
test -s "$archive"
217-
for attempt in 1 2 3; do
218-
echo "Uploading staged source to TOS (attempt $attempt/3)."
219-
upload_response="$(curl --fail-with-body --silent --show-error \
220-
--proto '=https' \
221-
--connect-timeout 30 \
222-
--max-time 60 \
223-
--request POST \
224-
--header "Content-Type: application/json" \
225-
--header "X-API-Key: $RELEASE_SERVER_API_KEY" \
226-
--data "$(jq -n --arg requestId "$job_id" \
227-
'{requestId:$requestId}')" \
228-
--url "$RELEASE_SERVER_URL/source-upload")"
229-
source_key="$(jq -er '.sourceKey' <<<"$upload_response")"
230-
upload_url="$(jq -er '.uploadUrl' <<<"$upload_response")"
231-
if curl --fail-with-body --silent --show-error \
232-
--proto '=https' \
233-
--connect-timeout 30 \
234-
--max-time 600 \
235-
--speed-limit 1024 \
236-
--speed-time 60 \
237-
--request PUT \
238-
--header "Content-Type: application/gzip" \
239-
--upload-file "$archive" \
240-
--write-out 'TOS upload status=%{http_code} uploaded=%{size_upload} speed=%{speed_upload} time=%{time_total}\n' \
241-
--url "$upload_url"; then
242-
break
243-
else
244-
upload_exit=$?
245-
fi
246-
if (( attempt == 3 )); then
247-
echo "TOS source upload failed after 3 attempts." >&2
248-
exit "$upload_exit"
249-
fi
250-
echo "TOS source upload failed; requesting a fresh signed URL before retrying." >&2
251-
sleep $((attempt * 10))
252-
done
253-
echo "job_id=$job_id" >> "$GITHUB_OUTPUT"
254-
echo "source_key=$source_key" >> "$GITHUB_OUTPUT"
165+
job_id = (
166+
f"{os.environ['GITHUB_RUN_ID']}-"
167+
f"{os.environ['GITHUB_RUN_ATTEMPT']}"
168+
)
169+
payload = json.dumps(
170+
{
171+
"repository": os.environ["GITHUB_REPOSITORY"],
172+
"gitSha": os.environ["GITHUB_SHA"],
173+
"requestId": job_id,
174+
"changelog": [os.environ["STUDIO_CHANGELOG"]],
175+
}
176+
).encode()
177+
request = urllib.request.Request(
178+
f"{os.environ['RELEASE_SERVER_URL']}/release",
179+
data=payload,
180+
headers={
181+
"Accept": "text/event-stream",
182+
"Content-Type": "application/json",
183+
"X-API-Key": os.environ["RELEASE_SERVER_API_KEY"],
184+
},
185+
method="POST",
186+
)
187+
def report(current):
188+
print(
189+
"Studio release "
190+
f"state={current['state']} stage={current['stage']} "
191+
f"message={current['message']}",
192+
flush=True,
193+
)
255194
256-
- name: Request Studio release
257-
id: release
258-
env:
259-
STUDIO_CHANGELOG: ${{ steps.changelog.outputs.value }}
260-
JOB_ID: ${{ steps.source.outputs.job_id }}
261-
SOURCE_KEY: ${{ steps.source.outputs.source_key }}
262-
run: |
263-
set -euo pipefail
264-
payload="$(jq -n \
265-
--arg repository "$GITHUB_REPOSITORY" \
266-
--arg gitSha "$GITHUB_SHA" \
267-
--arg requestId "$JOB_ID" \
268-
--arg changelog "$STUDIO_CHANGELOG" \
269-
--arg sourceKey "$SOURCE_KEY" \
270-
'{repository:$repository,gitSha:$gitSha,requestId:$requestId,changelog:[$changelog],sourceKey:$sourceKey}')"
271-
curl --fail-with-body --silent --show-error \
272-
--proto '=https' \
273-
--connect-timeout 30 \
274-
--max-time 1800 \
275-
--request POST \
276-
--header "Content-Type: application/json" \
277-
--header "X-API-Key: $RELEASE_SERVER_API_KEY" \
278-
--data "$payload" \
279-
--url "$RELEASE_SERVER_URL/release" >/dev/null
280-
echo "job_id=$JOB_ID" >> "$GITHUB_OUTPUT"
195+
terminal = None
196+
stream_error = None
197+
for attempt in range(1, 4):
198+
try:
199+
with urllib.request.urlopen(request, timeout=1800) as response:
200+
for raw_line in response:
201+
line = raw_line.decode().strip()
202+
if not line.startswith("data: "):
203+
continue
204+
terminal = json.loads(line.removeprefix("data: "))
205+
report(terminal)
206+
stream_error = None
207+
break
208+
except (
209+
OSError,
210+
urllib.error.URLError,
211+
http.client.HTTPException,
212+
) as error:
213+
stream_error = error
214+
print(
215+
f"Studio release stream interrupted (attempt {attempt}/3): "
216+
f"{error}",
217+
file=sys.stderr,
218+
flush=True,
219+
)
220+
if attempt < 3:
221+
time.sleep(2 * attempt)
281222
282-
- name: Wait for Studio release
283-
env:
284-
JOB_ID: ${{ steps.release.outputs.job_id }}
285-
run: |
286-
set -euo pipefail
287-
for _ in $(seq 1 180); do
288-
response="$(curl --fail-with-body --silent --show-error \
289-
--proto '=https' \
290-
--connect-timeout 30 \
291-
--max-time 60 \
292-
--header "X-API-Key: $RELEASE_SERVER_API_KEY" \
293-
--url "$RELEASE_SERVER_URL/status/$JOB_ID")"
294-
state="$(jq -r '.state' <<<"$response")"
295-
stage="$(jq -r '.stage' <<<"$response")"
296-
echo "Studio release state=$state stage=$stage"
297-
if [[ "$state" == "succeeded" ]]; then
298-
jq '{state,stage,result}' <<<"$response"
299-
exit 0
300-
fi
301-
if [[ "$state" == "failed" ]]; then
302-
jq '{state,stage,message,error}' <<<"$response" >&2
303-
exit 1
304-
fi
305-
sleep 10
306-
done
307-
echo "Studio release timed out after 30 minutes." >&2
308-
exit 1
223+
deadline = time.monotonic() + 1800
224+
missing_status = 0
225+
while terminal is None or terminal["state"] not in {
226+
"succeeded",
227+
"failed",
228+
}:
229+
if time.monotonic() >= deadline:
230+
raise RuntimeError(
231+
"Studio release did not reach a terminal state."
232+
) from stream_error
233+
status_request = urllib.request.Request(
234+
f"{os.environ['RELEASE_SERVER_URL']}/status/{job_id}",
235+
headers={
236+
"X-API-Key": os.environ["RELEASE_SERVER_API_KEY"],
237+
},
238+
)
239+
try:
240+
with urllib.request.urlopen(
241+
status_request,
242+
timeout=60,
243+
) as response:
244+
terminal = json.load(response)
245+
missing_status = 0
246+
report(terminal)
247+
except urllib.error.HTTPError as error:
248+
if error.code != 404:
249+
print(f"Status check failed: {error}", file=sys.stderr)
250+
else:
251+
missing_status += 1
252+
if missing_status >= 3 and terminal is None:
253+
raise RuntimeError(
254+
"Studio release request was not accepted."
255+
) from stream_error
256+
except (OSError, urllib.error.URLError) as error:
257+
print(f"Status check interrupted: {error}", file=sys.stderr)
258+
if terminal is None or terminal["state"] not in {
259+
"succeeded",
260+
"failed",
261+
}:
262+
time.sleep(2)
263+
if terminal["state"] != "succeeded":
264+
print(json.dumps(terminal, ensure_ascii=False), file=sys.stderr)
265+
raise SystemExit(1)
266+
print(json.dumps(terminal["result"], ensure_ascii=False, indent=2))
267+
PY

frontend/service/studio_release_server/app.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@
1717
from __future__ import annotations
1818

1919
import hmac
20-
from collections.abc import AsyncIterator
20+
import json
21+
import time
22+
from collections.abc import AsyncIterator, Iterator
2123
from contextlib import asynccontextmanager
2224

2325
from fastapi import Depends, FastAPI, Header, HTTPException, Request, status
26+
from fastapi.responses import StreamingResponse
2427

2528
from frontend.service.studio_release_server.builder import (
2629
StudioReleaseBuilder,
@@ -119,19 +122,41 @@ def source_upload(
119122

120123
@app.post(
121124
"/release",
122-
response_model=ReleaseStatus,
123-
response_model_by_alias=True,
124125
status_code=status.HTTP_202_ACCEPTED,
125126
dependencies=[Depends(require_api_key)],
126127
)
127-
def release(request: Request, payload: ReleaseRequest) -> ReleaseStatus:
128+
def release(request: Request, payload: ReleaseRequest) -> StreamingResponse:
128129
try:
129-
return request.app.state.release_service.submit(payload, run_inline=True)
130+
release_service = request.app.state.release_service
131+
release_service.submit(payload)
130132
except ReleaseConflictError as error:
131133
raise HTTPException(status_code=409, detail=str(error)) from error
132134
except ValueError as error:
133135
raise HTTPException(status_code=400, detail=str(error)) from error
134136

137+
def events() -> Iterator[str]:
138+
last_event = ""
139+
while True:
140+
current = release_service.get(payload.request_id)
141+
data = json.dumps(current.public_dict(), ensure_ascii=False)
142+
if data != last_event:
143+
yield f"data: {data}\n\n"
144+
last_event = data
145+
if current.state in {"succeeded", "failed"}:
146+
return
147+
yield ": keepalive\n\n"
148+
time.sleep(1)
149+
150+
return StreamingResponse(
151+
events(),
152+
status_code=status.HTTP_202_ACCEPTED,
153+
media_type="text/event-stream",
154+
headers={
155+
"Cache-Control": "no-cache",
156+
"X-Accel-Buffering": "no",
157+
},
158+
)
159+
135160
@app.get(
136161
"/status/{job_id}",
137162
response_model=ReleaseStatus,

0 commit comments

Comments
 (0)