Repository navigation
docs: add AgentKit survey invitation to README #1137
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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. | |
| name: Publish Studio Release | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| changelog: | |
| description: User-facing Studio updates, separated by semicolons | |
| required: true | |
| type: string | |
| thin_bundles: | |
| description: Publish Volcengine thin bundles (BytePlus remains full-only) | |
| required: true | |
| default: false | |
| type: boolean | |
| pull_request: | |
| paths: | |
| - '.github/workflows/publish-studio-release.yaml' | |
| - 'frontend/**' | |
| - 'veadk/**' | |
| - 'pyproject.toml' | |
| - 'uv.lock' | |
| - 'README.md' | |
| - 'LICENSE' | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: studio-release-${{ github.event_name }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| harness-sidecar-release-gate: | |
| if: >- | |
| github.repository == 'volcengine/veadk-python' && | |
| github.event_name == 'pull_request' | |
| uses: ./.github/workflows/harness-sidecar-release-gate.yaml | |
| verify: | |
| if: >- | |
| github.repository == 'volcengine/veadk-python' && | |
| (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| env: | |
| PIP_INDEX_URL: https://pypi.org/simple | |
| UV_DEFAULT_INDEX: https://pypi.org/simple | |
| npm_config_registry: https://registry.npmjs.org | |
| npm_config_replace_registry_host: always | |
| steps: | |
| - name: Check out release source | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 1 | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| cache: pip | |
| cache-dependency-path: | | |
| pyproject.toml | |
| uv.lock | |
| frontend/service/studio_release_server/offline_runtime.py | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22.17.0 | |
| cache: npm | |
| cache-dependency-path: frontend/package-lock.json | |
| - name: Set up uv | |
| uses: astral-sh/setup-uv@v6 | |
| with: | |
| enable-cache: true | |
| prune-cache: false | |
| cache-dependency-glob: | | |
| pyproject.toml | |
| uv.lock | |
| - name: Cache pinned Studio build inputs | |
| uses: actions/cache@v4 | |
| with: | |
| path: ${{ runner.temp }}/studio-release-inputs | |
| key: studio-release-inputs-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('veadk/cli/studio_dependencies.py', 'veadk/cli/agentkit_cli.py') }} | |
| - name: Validate frozen Studio runtime lock | |
| run: uv lock --check | |
| - name: Build and validate Studio bundle | |
| run: | | |
| set -euo pipefail | |
| output_dir="$RUNNER_TEMP/studio-release-output" | |
| version="$(TZ=Asia/Shanghai date +%Y%m%d%H%M%S)" | |
| export output_dir version | |
| uv run --frozen --group dev python - <<'PY' | |
| import hashlib | |
| import json | |
| import os | |
| import time | |
| import urllib.request | |
| from concurrent.futures import ThreadPoolExecutor | |
| from pathlib import Path | |
| from veadk.cli.agentkit_cli import download_agentkit_cli_archive | |
| from veadk.cli.studio_dependencies import ( | |
| STUDIO_AGENTKIT_CLI_ARTIFACT, | |
| STUDIO_DEPENDENCY_SOURCES, | |
| ) | |
| from veadk.cli.studio_package import build_frontend_assets | |
| from veadk.cli.studio_release import build_studio_release | |
| def prepare_dependency(dependency, destination): | |
| target = destination / dependency.filename | |
| if target.is_file(): | |
| if hashlib.sha256(target.read_bytes()).hexdigest() == dependency.sha256: | |
| print(f"Using cached {dependency.filename}", flush=True) | |
| return | |
| target.unlink() | |
| started = time.monotonic() | |
| if dependency == STUDIO_AGENTKIT_CLI_ARTIFACT: | |
| download_agentkit_cli_archive(target, dependency) | |
| else: | |
| with urllib.request.urlopen(dependency.url, timeout=120) as response: | |
| content = response.read(128 * 1024 * 1024 + 1) | |
| if len(content) > 128 * 1024 * 1024: | |
| raise ValueError(f"{dependency.filename} exceeds 128 MiB") | |
| if hashlib.sha256(content).hexdigest() != dependency.sha256: | |
| raise ValueError(f"{dependency.filename} checksum mismatch") | |
| target.write_bytes(content) | |
| print( | |
| f"Prepared {dependency.filename} in {time.monotonic() - started:.1f}s", | |
| flush=True, | |
| ) | |
| source_root = Path(os.environ["GITHUB_WORKSPACE"]) | |
| output_dir = Path(os.environ["output_dir"]) | |
| frontend_assets = Path(os.environ["RUNNER_TEMP"]) / "studio-release-frontend" | |
| dependency_inputs = Path(os.environ["RUNNER_TEMP"]) / "studio-release-inputs" | |
| dependency_inputs.mkdir(parents=True, exist_ok=True) | |
| changelog = ("Pull request release validation",) | |
| started = time.monotonic() | |
| with ThreadPoolExecutor(max_workers=5) as executor: | |
| preparation = [ | |
| executor.submit( | |
| build_frontend_assets, | |
| source_root, | |
| frontend_assets, | |
| changelog=changelog, | |
| ) | |
| ] | |
| for dependency in (*STUDIO_DEPENDENCY_SOURCES, STUDIO_AGENTKIT_CLI_ARTIFACT): | |
| preparation.append( | |
| executor.submit( | |
| prepare_dependency, | |
| dependency, | |
| dependency_inputs, | |
| ) | |
| ) | |
| for future in preparation: | |
| future.result() | |
| print(f"Build inputs ready in {time.monotonic() - started:.1f}s", flush=True) | |
| started = time.monotonic() | |
| bundle, manifest = build_studio_release( | |
| source_root=source_root, | |
| output_dir=output_dir, | |
| version=os.environ["version"], | |
| git_sha=os.environ["GITHUB_SHA"], | |
| changelog=changelog, | |
| frontend_assets=frontend_assets, | |
| dependency_wheels=dependency_inputs, | |
| ) | |
| print(f"Bundle built in {time.monotonic() - started:.1f}s", flush=True) | |
| print( | |
| json.dumps( | |
| { | |
| "bundle": str(bundle), | |
| "version": manifest.version, | |
| "gitSha": manifest.git_sha, | |
| "sha256": manifest.sha256, | |
| "size": manifest.size, | |
| } | |
| ) | |
| ) | |
| PY | |
| bundles=("$output_dir"/studio-bundle-*.zip) | |
| manifests=("$output_dir"/manifest-*.json) | |
| test "${#bundles[@]}" -eq 1 | |
| test "${#manifests[@]}" -eq 1 | |
| unzip -t "${bundles[0]}" | |
| - name: Simulate customer update and smoke-test Studio | |
| env: | |
| BYTEPLUS_ACCESS_KEY: smoke | |
| BYTEPLUS_SECRET_KEY: smoke | |
| VOLCENGINE_ACCESS_KEY: smoke | |
| VOLCENGINE_SECRET_KEY: smoke | |
| run: | | |
| set -euo pipefail | |
| test "$(uname -s)" = "Linux" | |
| test "$(uname -m)" = "x86_64" | |
| test "$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" = "3.12" | |
| COLD_START_DEADLINE_SECONDS=60 | |
| STUDIO_MAX_BUNDLE_BYTES=$((256 * 1024 * 1024)) | |
| bundle=("$RUNNER_TEMP"/studio-release-output/studio-bundle-*.zip) | |
| test "${#bundle[@]}" -eq 1 | |
| test "$(stat -c %s "${bundle[0]}")" -lt "$STUDIO_MAX_BUNDLE_BYTES" | |
| permission_probe_root="$(mktemp -d)" | |
| permission_probe="$permission_probe_root/package" | |
| permission_probe_bin="$permission_probe_root/bin" | |
| permission_probe_state="$permission_probe_root/state" | |
| mkdir "$permission_probe" "$permission_probe_bin" "$permission_probe_state" | |
| chmod 755 "$permission_probe_root" "$permission_probe" "$permission_probe_bin" | |
| chmod 700 "$permission_probe_state" | |
| sudo chown nobody "$permission_probe_state" | |
| unzip -q "${bundle[0]}" -d "$permission_probe" | |
| test "$(stat -c %a "$permission_probe/run.sh")" = "755" | |
| test "$(stat -c %a "$permission_probe/agentkit-linux-x64.tar.gz")" = "644" | |
| cat >"$permission_probe_bin/python3" <<'SH' | |
| #!/bin/bash | |
| set -eu | |
| if [[ "$*" == *"veadk.cli.studio_companion"* ]]; then | |
| test -r "$VEADK_STUDIO_AGENTKIT_CLI_ARCHIVE" | |
| touch "$PERMISSION_PROBE_STATE/companion-started" | |
| while [ ! -f "$PERMISSION_PROBE_STATE/studio-started" ]; do sleep 0.01; done | |
| exit 0 | |
| fi | |
| touch "$PERMISSION_PROBE_STATE/studio-started" | |
| while [ ! -f "$PERMISSION_PROBE_STATE/companion-started" ]; do sleep 0.01; done | |
| SH | |
| chmod 755 "$permission_probe_bin/python3" | |
| direct_stderr="$permission_probe_root/direct.stderr" | |
| if sudo -u nobody -H env \ | |
| PATH="$permission_probe_bin:/usr/bin:/bin" \ | |
| PERMISSION_PROBE_STATE="$permission_probe_state" \ | |
| "$permission_probe/run.sh" 2>"$direct_stderr"; then | |
| echo "entrypoint accepted an unexpected Studio exit" >&2 | |
| exit 1 | |
| fi | |
| grep -qx "studio_process_exited_unexpectedly" "$direct_stderr" | |
| sudo -u nobody -H rm \ | |
| "$permission_probe_state/companion-started" \ | |
| "$permission_probe_state/studio-started" | |
| chmod 644 "$permission_probe/run.sh" | |
| normalized_stderr="$permission_probe_root/normalized.stderr" | |
| if sudo -u nobody -H env \ | |
| PATH="$permission_probe_bin:/usr/bin:/bin" \ | |
| PERMISSION_PROBE_STATE="$permission_probe_state" \ | |
| bash "$permission_probe/run.sh" 2>"$normalized_stderr"; then | |
| echo "normalized entrypoint accepted an unexpected Studio exit" >&2 | |
| exit 1 | |
| fi | |
| grep -qx "studio_process_exited_unexpectedly" "$normalized_stderr" | |
| smoke_studio() { | |
| provider="$1" | |
| port="$2" | |
| package_dir="$RUNNER_TEMP/studio-release-package-${provider}" | |
| runtime_venv="$RUNNER_TEMP/studio-release-runtime-${provider}" | |
| log="$RUNNER_TEMP/studio-${provider}.log" | |
| response="$RUNNER_TEMP/studio-${provider}-ui-config.json" | |
| export package_dir | |
| test ! -e "$package_dir" | |
| test ! -e "$runtime_venv" | |
| uv run --frozen --group dev python - <<'PY' | |
| import os | |
| from pathlib import Path | |
| from veadk.cli.studio_self_update import extract_studio_bundle | |
| output_dir = Path(os.environ["RUNNER_TEMP"]) / "studio-release-output" | |
| bundle = next(output_dir.glob("studio-bundle-*.zip")) | |
| extract_studio_bundle(bundle, Path(os.environ["package_dir"])) | |
| PY | |
| uv venv --python 3.12 "$runtime_venv" | |
| ( | |
| cd "$package_dir" | |
| uv pip install --python "$runtime_venv/bin/python" -r requirements.txt | |
| ) | |
| ( | |
| cd "$package_dir" | |
| PATH="$runtime_venv/bin:$PATH" \ | |
| CLOUD_PROVIDER="$provider" \ | |
| AGENTKIT_CLOUD_PROVIDER="$provider" \ | |
| _FAAS_RUNTIME_PORT="$port" \ | |
| ./run.sh | |
| ) >"$log" 2>&1 & | |
| pid=$! | |
| started=$SECONDS | |
| while (( SECONDS - started < COLD_START_DEADLINE_SECONDS )); do | |
| if ! kill -0 "$pid" 2>/dev/null; then | |
| cat "$log" | |
| return 1 | |
| fi | |
| if curl --fail --silent \ | |
| "http://127.0.0.1:${port}/web/ui-config" >"$response"; then | |
| if ! curl --fail --silent --show-error \ | |
| "http://127.0.0.1:${port}/" >/dev/null; then | |
| kill "$pid" 2>/dev/null || true | |
| wait "$pid" 2>/dev/null || true | |
| cat "$log" | |
| return 1 | |
| fi | |
| kill "$pid" 2>/dev/null || true | |
| wait "$pid" || true | |
| return 0 | |
| fi | |
| sleep 1 | |
| done | |
| kill "$pid" 2>/dev/null || true | |
| wait "$pid" 2>/dev/null || true | |
| cat "$log" | |
| return 1 | |
| } | |
| smoke_studio byteplus 18877 | |
| smoke_studio volcengine 18878 | |
| package_dir="$RUNNER_TEMP/studio-release-package-volcengine" | |
| runtime_venv="$RUNNER_TEMP/studio-release-runtime-volcengine" | |
| ( | |
| cd "$package_dir" | |
| "$runtime_venv/bin/python" - <<'PY' | |
| from pathlib import Path | |
| import frontend | |
| import veadk | |
| import frontend.server.cronjobs | |
| import frontend.service.studio_release_server | |
| import frontend.service.studio_scheduler | |
| runtime_root = Path(veadk.__file__).resolve().parents[1] | |
| assert Path(frontend.__file__).resolve().is_relative_to(runtime_root) | |
| webui = Path(veadk.__file__).resolve().parent / "webui" / "index.html" | |
| assert webui.is_file() | |
| print(f"Validated installed Studio packages from {runtime_root}") | |
| PY | |
| ) | |
| release-context: | |
| if: >- | |
| github.event_name == 'workflow_dispatch' && | |
| github.repository == 'volcengine/veadk-python' && | |
| github.ref == 'refs/heads/main' | |
| needs: verify | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.release.outputs.version }} | |
| steps: | |
| - id: release | |
| name: Allocate shared release version | |
| run: echo "version=$(TZ=Asia/Shanghai date +%Y%m%d%H%M%S)" >> "$GITHUB_OUTPUT" | |
| publish: | |
| name: Publish Studio Release (${{ matrix.provider }}) | |
| if: >- | |
| github.event_name == 'workflow_dispatch' && | |
| github.repository == 'volcengine/veadk-python' && | |
| github.ref == 'refs/heads/main' | |
| needs: [verify, release-context] | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - provider: volcengine | |
| thin_bundles: ${{ inputs.thin_bundles }} | |
| url_secret: STUDIO_RELEASE_SERVER_URL | |
| key_secret: STUDIO_RELEASE_SERVER_API_KEY | |
| - provider: byteplus | |
| thin_bundles: false | |
| url_secret: BYTEPLUS_STUDIO_RELEASE_SERVER_URL | |
| key_secret: BYTEPLUS_STUDIO_RELEASE_SERVER_API_KEY | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| environment: studio-release | |
| env: | |
| RELEASE_PROVIDER: ${{ matrix.provider }} | |
| RELEASE_SERVER_URL: ${{ secrets[matrix.url_secret] }} | |
| RELEASE_SERVER_API_KEY: ${{ secrets[matrix.key_secret] }} | |
| RELEASE_VERSION: ${{ needs.release-context.outputs.version }} | |
| RELEASE_THIN_BUNDLES: ${{ matrix.thin_bundles }} | |
| steps: | |
| - name: Validate release server configuration | |
| run: | | |
| set -euo pipefail | |
| test -n "$RELEASE_SERVER_URL" || { | |
| echo "STUDIO_RELEASE_SERVER_URL is not configured." >&2 | |
| exit 1 | |
| } | |
| release_server_url="${RELEASE_SERVER_URL%/}" | |
| if [[ ! "$release_server_url" =~ ^https://[^[:space:]/]+(/[^[:space:]]*)?$ ]]; then | |
| echo "STUDIO_RELEASE_SERVER_URL must be an absolute HTTPS URL." >&2 | |
| exit 1 | |
| fi | |
| if (( ${#RELEASE_SERVER_API_KEY} < 32 )); then | |
| echo "STUDIO_RELEASE_SERVER_API_KEY must contain at least 32 characters." >&2 | |
| exit 1 | |
| fi | |
| echo "RELEASE_SERVER_URL=$release_server_url" >> "$GITHUB_ENV" | |
| - name: Request Studio release | |
| env: | |
| STUDIO_CHANGELOG: ${{ inputs.changelog }} | |
| run: | | |
| set -euo pipefail | |
| test -n "$STUDIO_CHANGELOG" || { | |
| echo "Studio release changelog is empty." >&2 | |
| exit 1 | |
| } | |
| python - <<'PY' | |
| import http.client | |
| import json | |
| import os | |
| import sys | |
| import time | |
| import urllib.error | |
| import urllib.request | |
| job_id = ( | |
| f"{os.environ['GITHUB_RUN_ID']}-" | |
| f"{os.environ['GITHUB_RUN_ATTEMPT']}-" | |
| f"{os.environ['RELEASE_PROVIDER']}" | |
| ) | |
| payload_data = { | |
| "repository": os.environ["GITHUB_REPOSITORY"], | |
| "gitSha": os.environ["GITHUB_SHA"], | |
| "requestId": job_id, | |
| "version": os.environ["RELEASE_VERSION"], | |
| "changelog": [os.environ["STUDIO_CHANGELOG"]], | |
| "thinBundle": os.environ["RELEASE_THIN_BUNDLES"].lower() == "true", | |
| } | |
| payload = json.dumps(payload_data).encode() | |
| request = urllib.request.Request( | |
| f"{os.environ['RELEASE_SERVER_URL']}/release", | |
| data=payload, | |
| headers={ | |
| "Accept": "text/event-stream", | |
| "Content-Type": "application/json", | |
| "X-API-Key": os.environ["RELEASE_SERVER_API_KEY"], | |
| }, | |
| method="POST", | |
| ) | |
| def report(current): | |
| print( | |
| "Studio release " | |
| f"state={current['state']} stage={current['stage']} " | |
| f"message={current['message']}", | |
| flush=True, | |
| ) | |
| terminal = None | |
| stream_error = None | |
| for attempt in range(1, 4): | |
| try: | |
| with urllib.request.urlopen(request, timeout=1800) as response: | |
| for raw_line in response: | |
| line = raw_line.decode().strip() | |
| if not line.startswith("data: "): | |
| continue | |
| terminal = json.loads(line.removeprefix("data: ")) | |
| report(terminal) | |
| stream_error = None | |
| break | |
| except ( | |
| OSError, | |
| urllib.error.URLError, | |
| http.client.HTTPException, | |
| ) as error: | |
| stream_error = error | |
| print( | |
| f"Studio release stream interrupted (attempt {attempt}/3): " | |
| f"{error}", | |
| file=sys.stderr, | |
| flush=True, | |
| ) | |
| if attempt < 3: | |
| time.sleep(2 * attempt) | |
| deadline = time.monotonic() + 1800 | |
| missing_status = 0 | |
| while terminal is None or terminal["state"] not in { | |
| "succeeded", | |
| "failed", | |
| }: | |
| if time.monotonic() >= deadline: | |
| raise RuntimeError( | |
| "Studio release did not reach a terminal state." | |
| ) from stream_error | |
| status_request = urllib.request.Request( | |
| f"{os.environ['RELEASE_SERVER_URL']}/status/{job_id}", | |
| headers={ | |
| "X-API-Key": os.environ["RELEASE_SERVER_API_KEY"], | |
| }, | |
| ) | |
| try: | |
| with urllib.request.urlopen( | |
| status_request, | |
| timeout=60, | |
| ) as response: | |
| terminal = json.load(response) | |
| missing_status = 0 | |
| report(terminal) | |
| except urllib.error.HTTPError as error: | |
| if error.code != 404: | |
| print(f"Status check failed: {error}", file=sys.stderr) | |
| else: | |
| missing_status += 1 | |
| if missing_status >= 3 and terminal is None: | |
| raise RuntimeError( | |
| "Studio release request was not accepted." | |
| ) from stream_error | |
| except (OSError, urllib.error.URLError) as error: | |
| print(f"Status check interrupted: {error}", file=sys.stderr) | |
| if terminal is None or terminal["state"] not in { | |
| "succeeded", | |
| "failed", | |
| }: | |
| time.sleep(2) | |
| if terminal["state"] != "succeeded": | |
| print(json.dumps(terminal, ensure_ascii=False), file=sys.stderr) | |
| 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 |