mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(release): publish via GitHub REST — gh CLI is not installed in any image (#331)
ReleaseExecutor.publish_release shelled out to 'gh release create', but no
Dockerfile installs the gh CLI (verified missing in the live orchestrator
container), so an armed release manager died at publish AFTER the release
commit was pushed. Publish now POSTs /repos/{owner}/{repo}/releases with the
project's decrypted token — same auth/httpx pattern as PR creation, same
fail-closed semantics (non-201 -> structured publish_failed, CEO retries;
the 300s deadline is the httpx client timeout). Subprocess publish-timeout
test replaced with REST-path tests (201/non-201/transport-error/no-token).
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Release manager publish no longer depends on a `gh` binary that was never installed.** `ReleaseExecutor.publish_release` shelled out to `gh release create`, but no image ships the gh CLI — with `ROBOCO_RELEASE_MANAGER_ENABLED` armed, every publish would have died on a missing binary after the release commit was already pushed (verified against the live 0.19.0 orchestrator container). The publish is now a GitHub REST `POST /repos/{owner}/{repo}/releases` authenticated with the project's decrypted token — the same auth + httpx pattern as PR creation — with the same fail-closed semantics (non-201 → structured `publish_failed`, CEO retries; the 300s deadline is now the HTTP client timeout).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Leaner agent/orchestrator images (~1.65GB less, cache-stable deploys).** Playwright + its Chromium and system libs are gone from `agent-dev-fe`/`agent-qa-fe` (~770MB each — verified unused repo-wide: panel tests are vitest, the e2e harness is scripted Python; browser-based FE QA is a designed follow-up, and the re-add is two lines scoped to `chromium-headless-shell` in the QA image only). The `agent-grok` image drops a redundant `chown -R` that duplicated the entire 149MB CLI tree into a second layer. The runner-stage `/app` COPY in `agent-base` and `orchestrator` is split `.venv`-first/source-last, so a source-only deploy re-layers ~13MB instead of ~380MB per image, and `agent-base`'s single 813MB apt+node+claude-code RUN is split so a CLI version bump no longer re-downloads the OS/node layer. Hygiene: gitignored `docs/internal/` no longer leaks into the orchestrator image from a working-tree build, and the `uv` helper image is pinned (`0.11`) instead of `:latest`. All four rebuilt images pass runtime probes (claude/git/jq/node/uv/pnpm/grok binaries, `import roboco`, docs/alembic/agents trees present); cache-stability proven by rebuild log (`.venv` layer CACHED across a source-only change).
|
- **Leaner agent/orchestrator images (~1.65GB less, cache-stable deploys).** Playwright + its Chromium and system libs are gone from `agent-dev-fe`/`agent-qa-fe` (~770MB each — verified unused repo-wide: panel tests are vitest, the e2e harness is scripted Python; browser-based FE QA is a designed follow-up, and the re-add is two lines scoped to `chromium-headless-shell` in the QA image only). The `agent-grok` image drops a redundant `chown -R` that duplicated the entire 149MB CLI tree into a second layer. The runner-stage `/app` COPY in `agent-base` and `orchestrator` is split `.venv`-first/source-last, so a source-only deploy re-layers ~13MB instead of ~380MB per image, and `agent-base`'s single 813MB apt+node+claude-code RUN is split so a CLI version bump no longer re-downloads the OS/node layer. Hygiene: gitignored `docs/internal/` no longer leaks into the orchestrator image from a working-tree build, and the `uv` helper image is pinned (`0.11`) instead of `:latest`. All four rebuilt images pass runtime probes (claude/git/jq/node/uv/pnpm/grok binaries, `import roboco`, docs/alembic/agents trees present); cache-stability proven by rebuild log (`.venv` layer CACHED across a source-only change).
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ before any publish. Idempotent: re-running an already-published version is a
|
|||||||
no-op. The bump/gate/commit/publish steps live behind a ``ReleaseOps`` seam so
|
no-op. The bump/gate/commit/publish steps live behind a ``ReleaseOps`` seam so
|
||||||
the fail-closed ORDERING (the correctness this feature exists to guarantee) is
|
the fail-closed ORDERING (the correctness this feature exists to guarantee) is
|
||||||
unit-tested deterministically; the production ``_GitReleaseOps`` performs the
|
unit-tested deterministically; the production ``_GitReleaseOps`` performs the
|
||||||
real git / ``make quality`` / ``gh`` work on a writable clone.
|
real git / ``make quality`` work on a writable clone and publishes the GitHub
|
||||||
|
release over REST (the orchestrator image ships no ``gh`` binary).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -155,10 +156,10 @@ class ReleaseExecutor:
|
|||||||
version, report.drafted_changelog
|
version, report.drafted_changelog
|
||||||
)
|
)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
# ``gh release create`` failed (auth/quota/network). The commit is
|
# The GitHub release POST failed (auth/quota/network). The commit
|
||||||
# already pushed and CI is green, so the release is half-landed —
|
# is already pushed and CI is green, so the release is half-landed
|
||||||
# surface it as a structured outcome (not a 500) so the CEO can
|
# — surface it as a structured outcome (not a 500) so the CEO can
|
||||||
# retry ``gh release create`` for the same version.
|
# retry the publish for the same version.
|
||||||
logger.error("release publish failed", error=str(exc)[:300])
|
logger.error("release publish failed", error=str(exc)[:300])
|
||||||
return ReleaseResult(
|
return ReleaseResult(
|
||||||
status="publish_failed",
|
status="publish_failed",
|
||||||
@@ -166,7 +167,7 @@ class ReleaseExecutor:
|
|||||||
files_changed=files,
|
files_changed=files,
|
||||||
commit_sha=commit_sha,
|
commit_sha=commit_sha,
|
||||||
release_url=None,
|
release_url=None,
|
||||||
detail=f"gh release create failed — not published (fail-closed): {exc}",
|
detail=f"release publish failed — not published (fail-closed): {exc}",
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"release published",
|
"release published",
|
||||||
@@ -185,25 +186,28 @@ class ReleaseExecutor:
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Production ops — real git / make / gh on a writable clone.
|
# Production ops — real git / make on a writable clone; publish via REST.
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
_CI_POLL_INTERVAL_SECONDS = 30
|
_CI_POLL_INTERVAL_SECONDS = 30
|
||||||
_CI_MAX_POLLS = 80 # ~40 min ceiling
|
_CI_MAX_POLLS = 80 # ~40 min ceiling
|
||||||
|
|
||||||
# Subprocess deadlines. A hung git / make / gh would otherwise block the
|
# Subprocess deadlines. A hung git / make would otherwise block the
|
||||||
# CEO-gated release loop indefinitely. Each is generous enough that a
|
# CEO-gated release loop indefinitely. Each is generous enough that a
|
||||||
# legitimate, slow operation is never wrongly aborted — only a true hang fails
|
# legitimate, slow operation is never wrongly aborted — only a true hang fails
|
||||||
# closed. Mirrors the quality-gate ``_run_one`` kill-on-timeout idiom.
|
# closed. Mirrors the quality-gate ``_run_one`` kill-on-timeout idiom.
|
||||||
_GIT_OP_TIMEOUT_SECONDS = 300 # git add/commit/rev-parse/ls-remote/push
|
_GIT_OP_TIMEOUT_SECONDS = 300 # git add/commit/rev-parse/ls-remote/push
|
||||||
_RELEASE_GATE_TIMEOUT_SECONDS = 1800 # make quality — full ruff/mypy/pytest suite
|
_RELEASE_GATE_TIMEOUT_SECONDS = 1800 # make quality — full ruff/mypy/pytest suite
|
||||||
_PUBLISH_TIMEOUT_SECONDS = 300 # gh release create
|
_PUBLISH_TIMEOUT_SECONDS = 300 # GitHub release POST (httpx client timeout)
|
||||||
_CLONE_TIMEOUT_SECONDS = 600 # git clone / rm -rf the release clone
|
_CLONE_TIMEOUT_SECONDS = 600 # git clone / rm -rf the release clone
|
||||||
|
|
||||||
# The conventional non-zero rc a timed-out subprocess reports so every caller's
|
# The conventional non-zero rc a timed-out subprocess reports so every caller's
|
||||||
# fail-closed branch (rc != 0) fires instead of hanging the release loop.
|
# fail-closed branch (rc != 0) fires instead of hanging the release loop.
|
||||||
_TIMEOUT_RC = 124
|
_TIMEOUT_RC = 124
|
||||||
|
|
||||||
|
# GitHub REST "created" — the only success status for the release POST.
|
||||||
|
_HTTP_CREATED = 201
|
||||||
|
|
||||||
|
|
||||||
async def _await_proc(
|
async def _await_proc(
|
||||||
proc: asyncio.subprocess.Process, timeout: float
|
proc: asyncio.subprocess.Process, timeout: float
|
||||||
@@ -384,32 +388,49 @@ class _GitReleaseOps:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def publish_release(self, version: str, notes: str) -> str:
|
async def publish_release(self, version: str, notes: str) -> str:
|
||||||
|
# REST, not `gh release create` — the orchestrator image ships no gh
|
||||||
|
# binary, so the CLI path fails at publish time with a missing binary.
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from roboco.config import settings
|
||||||
|
from roboco.services.git import GitService
|
||||||
|
from roboco.services.project import ProjectService
|
||||||
|
|
||||||
tag = f"v{version}"
|
tag = f"v{version}"
|
||||||
proc = await asyncio.create_subprocess_exec(
|
token = await ProjectService(self._session).get_decrypted_token_by_slug(
|
||||||
"gh",
|
self._slug
|
||||||
"release",
|
|
||||||
"create",
|
|
||||||
tag,
|
|
||||||
"--title",
|
|
||||||
tag,
|
|
||||||
"--notes",
|
|
||||||
notes,
|
|
||||||
"--target",
|
|
||||||
self._default_branch,
|
|
||||||
cwd=str(self._root),
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
|
||||||
)
|
)
|
||||||
rc, out = await _await_proc(proc, _PUBLISH_TIMEOUT_SECONDS)
|
if not token:
|
||||||
text = out.strip()
|
raise RuntimeError(f"release publish failed: no git token for {self._slug}")
|
||||||
if rc != 0:
|
owner, repo = GitService._parse_git_url(self._git_url)
|
||||||
logger.error("gh release create failed", error=text[:300])
|
api_base = settings.github_api_base_url.rstrip("/")
|
||||||
raise RuntimeError(f"gh release create failed: {text[:200]}")
|
try:
|
||||||
url = next(
|
async with httpx.AsyncClient(timeout=_PUBLISH_TIMEOUT_SECONDS) as client:
|
||||||
(line.strip() for line in text.splitlines() if line.startswith("http")),
|
resp = await client.post(
|
||||||
"",
|
f"{api_base}/repos/{owner}/{repo}/releases",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"tag_name": tag,
|
||||||
|
"name": tag,
|
||||||
|
"body": notes,
|
||||||
|
"target_commitish": self._default_branch,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return url
|
except httpx.HTTPError as e:
|
||||||
|
raise RuntimeError(f"release publish failed: {e}") from e
|
||||||
|
if resp.status_code != _HTTP_CREATED:
|
||||||
|
detail = resp.text[:200]
|
||||||
|
logger.error(
|
||||||
|
"release publish failed", status=resp.status_code, error=detail
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"release publish failed: HTTP {resp.status_code}: {detail}"
|
||||||
|
)
|
||||||
|
return str(resp.json().get("html_url") or "")
|
||||||
|
|
||||||
|
|
||||||
def _bump_uv_lock(text: str, old: str, new: str) -> str:
|
def _bump_uv_lock(text: str, old: str, new: str) -> str:
|
||||||
|
|||||||
@@ -190,17 +190,17 @@ async def test_commit_push_failure_returns_structured_commit_failed() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_publish_failure_returns_structured_publish_failed() -> None:
|
async def test_publish_failure_returns_structured_publish_failed() -> None:
|
||||||
"""#88: a RuntimeError from ``gh release create`` (auth/quota/network) becomes
|
"""#88: a RuntimeError from the GitHub release POST (auth/quota/network) becomes
|
||||||
a structured ``publish_failed`` result. The commit is already pushed and CI
|
a structured ``publish_failed`` result. The commit is already pushed and CI
|
||||||
is green, so the release is half-landed — the CEO can retry ``gh release
|
is green, so the release is half-landed — the CEO can retry the publish
|
||||||
create`` for the same version (the executor is idempotent on the commit
|
create`` for the same version (the executor is idempotent on the commit
|
||||||
side). No 500."""
|
side). No 500."""
|
||||||
ops = _FakeOps(publish_raises="gh release create failed: forbidden")
|
ops = _FakeOps(publish_raises="release publish failed: HTTP 403: forbidden")
|
||||||
result = await ReleaseExecutor(ops).execute(_report())
|
result = await ReleaseExecutor(ops).execute(_report())
|
||||||
assert result.status == "publish_failed"
|
assert result.status == "publish_failed"
|
||||||
assert result.commit_sha == "deadbeef"
|
assert result.commit_sha == "deadbeef"
|
||||||
assert result.release_url is None
|
assert result.release_url is None
|
||||||
assert "gh release create failed" in result.detail
|
assert "release publish failed" in result.detail
|
||||||
assert ops.calls.count("publish") == _ONE
|
assert ops.calls.count("publish") == _ONE
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""``_GitReleaseOps`` subprocesses (git, ``make quality``, ``gh release create``,
|
"""``_GitReleaseOps`` subprocesses (git, ``make quality``, the release-clone
|
||||||
the release-clone ``git clone``) are wrapped in ``asyncio.wait_for`` with a
|
``git clone``) are wrapped in ``asyncio.wait_for`` with a
|
||||||
kill-on-timeout fail-close so a hung child cannot block the release loop.
|
kill-on-timeout fail-close so a hung child cannot block the release loop.
|
||||||
|
|
||||||
These tests hang the subprocess (a never-resolving ``communicate``) and patch
|
These tests hang the subprocess (a never-resolving ``communicate``) and patch
|
||||||
@@ -11,8 +11,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
from roboco.services.project import ProjectService
|
||||||
from roboco.services.release_executor import (
|
from roboco.services.release_executor import (
|
||||||
_CLONE_TIMEOUT_SECONDS,
|
_CLONE_TIMEOUT_SECONDS,
|
||||||
_GIT_OP_TIMEOUT_SECONDS,
|
_GIT_OP_TIMEOUT_SECONDS,
|
||||||
@@ -23,12 +26,15 @@ from roboco.services.release_executor import (
|
|||||||
_run,
|
_run,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
# Floors encoding the logical-regression guard: a deadline below these would
|
# Floors encoding the logical-regression guard: a deadline below these would
|
||||||
# silently abort a legitimate slow release. Named (not magic) for ruff PLR2004.
|
# silently abort a legitimate slow release. Named (not magic) for ruff PLR2004.
|
||||||
_MIN_GATE_TIMEOUT = 1800 # full make quality suite — ruff/mypy/pytest
|
_MIN_GATE_TIMEOUT = 1800 # full make quality suite — ruff/mypy/pytest
|
||||||
_MIN_GIT_OP_TIMEOUT = 300 # network push / ls-remote
|
_MIN_GIT_OP_TIMEOUT = 300 # network push / ls-remote
|
||||||
_MIN_CLONE_TIMEOUT = 600 # full clone on a slow link
|
_MIN_CLONE_TIMEOUT = 600 # full clone on a slow link
|
||||||
_MIN_PUBLISH_TIMEOUT = 120 # gh release create
|
_MIN_PUBLISH_TIMEOUT = 120 # GitHub release POST (httpx client timeout)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -99,6 +105,9 @@ def _ops() -> _GitReleaseOps:
|
|||||||
ops._git_url = "https://github.com/o/roboco"
|
ops._git_url = "https://github.com/o/roboco"
|
||||||
ops._git_prefix = []
|
ops._git_prefix = []
|
||||||
ops._ci_workflow = None
|
ops._ci_workflow = None
|
||||||
|
# publish_release resolves the token via a (monkeypatched) ProjectService;
|
||||||
|
# the session itself is never touched in these tests.
|
||||||
|
ops._session = cast("AsyncSession", None)
|
||||||
return ops
|
return ops
|
||||||
|
|
||||||
|
|
||||||
@@ -199,24 +208,110 @@ async def test_run_gate_times_out_returns_false(
|
|||||||
assert proc.killed
|
assert proc.killed
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(
|
||||||
|
self, status_code: int, body: dict[str, str] | None = None, text: str = ""
|
||||||
|
) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self._body = body or {}
|
||||||
|
self.text = text
|
||||||
|
|
||||||
|
def json(self) -> dict[str, str]:
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAsyncClient:
|
||||||
|
"""Stands in for ``httpx.AsyncClient`` — canned response or raised error."""
|
||||||
|
|
||||||
|
response: _FakeResponse | None = None
|
||||||
|
raises: Exception | None = None
|
||||||
|
last_url: str = ""
|
||||||
|
last_json: dict[str, str] | None = None
|
||||||
|
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None: ...
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _FakeAsyncClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def post(self, url: str, **kwargs: object) -> _FakeResponse:
|
||||||
|
type(self).last_url = url
|
||||||
|
json_payload = kwargs.get("json")
|
||||||
|
assert json_payload is None or isinstance(json_payload, dict)
|
||||||
|
type(self).last_json = json_payload
|
||||||
|
err = type(self).raises
|
||||||
|
if err is not None:
|
||||||
|
raise err
|
||||||
|
resp = type(self).response
|
||||||
|
assert resp is not None
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_publish_deps(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, token: str | None = "tok"
|
||||||
|
) -> None:
|
||||||
|
async def _token(_self: ProjectService, _slug: str) -> str | None:
|
||||||
|
return token
|
||||||
|
|
||||||
|
monkeypatch.setattr(ProjectService, "get_decrypted_token_by_slug", _token)
|
||||||
|
monkeypatch.setattr("httpx.AsyncClient", _FakeAsyncClient)
|
||||||
|
_FakeAsyncClient.response = None
|
||||||
|
_FakeAsyncClient.raises = None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_publish_release_times_out_raises(
|
async def test_publish_release_posts_rest_and_returns_url(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A hung ``gh release create`` raises RuntimeError (fail-closed — never
|
"""The publish is a GitHub REST POST (no ``gh`` binary in the orchestrator
|
||||||
reports a bogus published URL) and kills the child."""
|
image) hitting /repos/{owner}/{repo}/releases with the tag payload."""
|
||||||
monkeypatch.setattr(
|
_patch_publish_deps(monkeypatch)
|
||||||
"roboco.services.release_executor._PUBLISH_TIMEOUT_SECONDS", 0.05
|
_FakeAsyncClient.response = _FakeResponse(
|
||||||
)
|
201, body={"html_url": "https://github.com/o/roboco/releases/tag/v1.0.0"}
|
||||||
proc = _HangingProc()
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"roboco.services.release_executor.asyncio.create_subprocess_exec",
|
|
||||||
_exec_returning(proc),
|
|
||||||
)
|
)
|
||||||
ops = _ops()
|
ops = _ops()
|
||||||
with pytest.raises(RuntimeError, match="timed out"):
|
url = await ops.publish_release("1.0.0", "notes")
|
||||||
await asyncio.wait_for(ops.publish_release("1.0.0", "notes"), timeout=2.0)
|
assert url.endswith("/releases/tag/v1.0.0")
|
||||||
assert proc.killed
|
assert _FakeAsyncClient.last_url.endswith("/repos/o/roboco/releases")
|
||||||
|
assert _FakeAsyncClient.last_json is not None
|
||||||
|
assert _FakeAsyncClient.last_json["tag_name"] == "v1.0.0"
|
||||||
|
assert _FakeAsyncClient.last_json["target_commitish"] == "master"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_release_non_201_raises(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_patch_publish_deps(monkeypatch)
|
||||||
|
_FakeAsyncClient.response = _FakeResponse(422, text="already_exists")
|
||||||
|
ops = _ops()
|
||||||
|
with pytest.raises(RuntimeError, match="HTTP 422"):
|
||||||
|
await ops.publish_release("1.0.0", "notes")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_release_network_error_raises(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A transport error (incl. the httpx client-timeout on a hung POST) fails
|
||||||
|
closed as RuntimeError — never a bogus published URL."""
|
||||||
|
_patch_publish_deps(monkeypatch)
|
||||||
|
_FakeAsyncClient.raises = httpx.ReadTimeout("hung POST")
|
||||||
|
ops = _ops()
|
||||||
|
with pytest.raises(RuntimeError, match="release publish failed"):
|
||||||
|
await ops.publish_release("1.0.0", "notes")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_release_no_token_raises(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_patch_publish_deps(monkeypatch, token=None)
|
||||||
|
ops = _ops()
|
||||||
|
with pytest.raises(RuntimeError, match="no git token"):
|
||||||
|
await ops.publish_release("1.0.0", "notes")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user