Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a4d66ab5c | ||
|
|
14c9200179 | ||
|
|
85ae272b1f | ||
|
|
a0c4c1c7f0 | ||
|
|
fd0c4de31e | ||
|
|
8f7af97335 | ||
|
|
a14d838564 | ||
|
|
7dcb9c03c0 | ||
|
|
6f2b110c17 |
@@ -75,7 +75,7 @@ jobs:
|
|||||||
needs: [detect-type]
|
needs: [detect-type]
|
||||||
if: needs.detect-type.outputs.is-release == 'false'
|
if: needs.detect-type.outputs.is-release == 'false'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
timeout-minutes: 10
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -94,12 +94,32 @@ jobs:
|
|||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 -m devx.ci.release
|
python3 -m devx.ci.release
|
||||||
|
- name: Publish release
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
echo "No tag found — skipping publish"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
HEAD_MSG=$(git log -1 --format=%s)
|
||||||
|
if echo "$HEAD_MSG" | grep -q "^release: ${TAG}"; then
|
||||||
|
echo "Publishing release $TAG..."
|
||||||
|
python3 -m devx.ci.publish "$TAG" "${{ github.repository }}"
|
||||||
|
else
|
||||||
|
echo "HEAD is not a release commit for $TAG — skipping publish"
|
||||||
|
fi
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 -m devx.tools.install_tools --tool tea
|
python3 -m devx.tools.install_tools --tool tea
|
||||||
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.11.1] - 2026-06-24
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Add build/twine to ci deps, activate venv in notify_failure
|
||||||
|
|
||||||
|
## [0.11.0] - 2026-06-24
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add publish step to post-merge release job, make publish idempotent
|
||||||
|
|
||||||
|
## [0.10.2] - 2026-06-24
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Badge generation respects pyproject.toml testpaths, shows stdout in warnings
|
||||||
|
|
||||||
## [0.10.1] - 2026-06-24
|
## [0.10.1] - 2026-06-24
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ version = {attr = "devx.__version__"}
|
|||||||
ci = [
|
ci = [
|
||||||
"pytest>=9.1.0",
|
"pytest>=9.1.0",
|
||||||
"pytest-cov>=7.1.0",
|
"pytest-cov>=7.1.0",
|
||||||
|
"build>=1.5.0",
|
||||||
|
"twine>=6.2.0",
|
||||||
]
|
]
|
||||||
# Lint and type-checking tools (quality job)
|
# Lint and type-checking tools (quality job)
|
||||||
lint = [
|
lint = [
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.10.1"
|
__version__ = "0.11.1"
|
||||||
|
|||||||
@@ -211,6 +211,17 @@ def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> Non
|
|||||||
click.echo(_("--skip-build: skipping package build and PyPI publish."))
|
click.echo(_("--skip-build: skipping package build and PyPI publish."))
|
||||||
|
|
||||||
tea = TeaCLI(repo=repo)
|
tea = TeaCLI(repo=repo)
|
||||||
|
|
||||||
|
# Check if release already exists (idempotent — avoids failure when
|
||||||
|
# called multiple times, e.g. by both post-merge and publish workflows)
|
||||||
|
try:
|
||||||
|
releases = tea.list_releases(repo)
|
||||||
|
if any(r.get("tag_name") == tag for r in releases):
|
||||||
|
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
|
||||||
|
return
|
||||||
|
except TeaCLIError:
|
||||||
|
pass # If listing fails, proceed to create
|
||||||
|
|
||||||
release_body = generate_release_notes(tag)
|
release_body = generate_release_notes(tag)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -239,6 +239,36 @@ def doc_coverage_color(pct: int) -> str:
|
|||||||
return "orange"
|
return "orange"
|
||||||
|
|
||||||
|
|
||||||
|
def detect_testpaths(repo_root: Path) -> list[str]:
|
||||||
|
"""Detect test paths from pyproject.toml or filesystem.
|
||||||
|
|
||||||
|
Parses ``testpaths`` in ``[tool.pytest.ini_options]`` from
|
||||||
|
pyproject.toml. Falls back to ``["tests"]`` if the tests/
|
||||||
|
directory exists. Returns an empty list if no test paths
|
||||||
|
are found (pytest will use its own defaults).
|
||||||
|
"""
|
||||||
|
pyproject = repo_root / "pyproject.toml"
|
||||||
|
if pyproject.exists():
|
||||||
|
content = pyproject.read_text()
|
||||||
|
# Match: testpaths = ["dir1", "dir2"]
|
||||||
|
match = re.search(r"testpaths\s*=\s*\[([^\]]+)\]", content)
|
||||||
|
if match:
|
||||||
|
paths = re.findall(r'["\']([^"\']+)["\']', match.group(1))
|
||||||
|
resolved = []
|
||||||
|
for p in paths:
|
||||||
|
p = p.strip()
|
||||||
|
if (repo_root / p).exists():
|
||||||
|
resolved.append(p)
|
||||||
|
if resolved:
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
# Fallback: tests/ directory
|
||||||
|
tests_dir = repo_root / "tests"
|
||||||
|
if tests_dir.is_dir():
|
||||||
|
return ["tests"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], dict[str, str | int]]:
|
def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], dict[str, str | int]]:
|
||||||
"""Run pytest-cov and collect coverage + test count badges.
|
"""Run pytest-cov and collect coverage + test count badges.
|
||||||
|
|
||||||
@@ -251,8 +281,8 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
|
|||||||
click.echo(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)")
|
click.echo(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)")
|
||||||
return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
|
return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
|
||||||
|
|
||||||
tests_dir = repo_root / "tests"
|
testpaths = detect_testpaths(repo_root)
|
||||||
testpaths: list[str] = [str(tests_dir)] if tests_dir.is_dir() else []
|
click.echo(f" Test paths: {testpaths or '(pytest defaults)'}")
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
@@ -273,7 +303,8 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
|
|||||||
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
||||||
else:
|
else:
|
||||||
click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})")
|
click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})")
|
||||||
click.echo(f" pytest stderr: {stderr.strip()[:200]}")
|
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
|
||||||
|
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
|
||||||
cov_badge = make_badge("coverage", "unknown", "red")
|
cov_badge = make_badge("coverage", "unknown", "red")
|
||||||
|
|
||||||
test_count = extract_test_count(combined)
|
test_count = extract_test_count(combined)
|
||||||
@@ -281,7 +312,8 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
|
|||||||
tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
||||||
else:
|
else:
|
||||||
click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})")
|
click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})")
|
||||||
click.echo(f" pytest stderr: {stderr.strip()[:200]}")
|
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
|
||||||
|
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
|
||||||
tests_badge = make_badge("tests", "unknown", "red")
|
tests_badge = make_badge("tests", "unknown", "red")
|
||||||
|
|
||||||
return cov_badge, tests_badge
|
return cov_badge, tests_badge
|
||||||
|
|||||||
@@ -657,6 +657,13 @@
|
|||||||
"ru": "Директория molecule не найдена: {path}",
|
"ru": "Директория molecule не найдена: {path}",
|
||||||
"zh": "未找到 molecule 目录: {path}"
|
"zh": "未找到 molecule 目录: {path}"
|
||||||
},
|
},
|
||||||
|
"Gitea release {tag} already exists — skipping creation.": {
|
||||||
|
"bg": "Gitea release {tag} вече съществува — прескачане на създаването.",
|
||||||
|
"de": "Gitea-Release {tag} existiert bereits — Erstellung übersprungen.",
|
||||||
|
"en": "Gitea release {tag} already exists — skipping creation.",
|
||||||
|
"ru": "Gitea release {tag} уже существует — пропуск создания.",
|
||||||
|
"zh": "Gitea release {tag} 已存在 — 跳过创建。"
|
||||||
|
},
|
||||||
"Nice! Gitea release {tag} created.": {
|
"Nice! Gitea release {tag} created.": {
|
||||||
"bg": "Отлично! Gitea release {tag} е създаден.",
|
"bg": "Отлично! Gitea release {tag} е създаден.",
|
||||||
"de": "Prima! Gitea-Release {tag} erstellt.",
|
"de": "Prima! Gitea-Release {tag} erstellt.",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from devx.tools.generate_badges import (
|
|||||||
coverage_color,
|
coverage_color,
|
||||||
detect_coverage_target,
|
detect_coverage_target,
|
||||||
detect_package_name,
|
detect_package_name,
|
||||||
|
detect_testpaths,
|
||||||
doc_coverage_color,
|
doc_coverage_color,
|
||||||
extract_coverage,
|
extract_coverage,
|
||||||
extract_doc_coverage,
|
extract_doc_coverage,
|
||||||
@@ -97,6 +98,32 @@ class TestDetectCoverageTarget:
|
|||||||
assert detect_coverage_target(tmp_path) is None
|
assert detect_coverage_target(tmp_path) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectTestpaths:
|
||||||
|
def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||||
|
(tmp_path / "scripts" / "tests").mkdir(parents=True)
|
||||||
|
(tmp_path / "tests" / "unit").mkdir(parents=True)
|
||||||
|
(tmp_path / "pyproject.toml").write_text(
|
||||||
|
'[tool.pytest.ini_options]\ntestpaths = ["scripts/tests", "tests/unit"]\n'
|
||||||
|
)
|
||||||
|
assert detect_testpaths(tmp_path) == ["scripts/tests", "tests/unit"]
|
||||||
|
|
||||||
|
def test_filters_nonexistent_paths(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||||
|
(tmp_path / "tests").mkdir()
|
||||||
|
(tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\ntestpaths = ["tests", "nonexistent"]\n')
|
||||||
|
assert detect_testpaths(tmp_path) == ["tests"]
|
||||||
|
|
||||||
|
def test_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||||
|
(tmp_path / "tests").mkdir()
|
||||||
|
assert detect_testpaths(tmp_path) == ["tests"]
|
||||||
|
|
||||||
|
def test_returns_empty_when_no_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||||
|
assert detect_testpaths(tmp_path) == []
|
||||||
|
|
||||||
|
def test_returns_empty_when_pyproject_has_no_testpaths(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||||
|
(tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]\naddopts = '-ra'\n")
|
||||||
|
assert detect_testpaths(tmp_path) == []
|
||||||
|
|
||||||
|
|
||||||
class TestRunCommand:
|
class TestRunCommand:
|
||||||
@patch("devx.tools.generate_badges.subprocess.run")
|
@patch("devx.tools.generate_badges.subprocess.run")
|
||||||
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
|
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
|
||||||
@@ -256,16 +283,22 @@ class TestReadVersion:
|
|||||||
|
|
||||||
class TestCollectCoverageAndTests:
|
class TestCollectCoverageAndTests:
|
||||||
@patch("devx.tools.generate_badges.run_command")
|
@patch("devx.tools.generate_badges.run_command")
|
||||||
|
@patch("devx.tools.generate_badges.detect_testpaths", return_value=["tests"])
|
||||||
@patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
|
@patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
|
||||||
def test_extracts_coverage_and_tests(self, mock_target: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
def test_extracts_coverage_and_tests(
|
||||||
|
self, mock_target: MagicMock, mock_testpaths: MagicMock, mock_run: MagicMock, tmp_path: Path
|
||||||
|
) -> None: # type: ignore[no-untyped-def]
|
||||||
mock_run.return_value = (0, "1018 passed in 4.23s\nTOTAL 3546 0 100%", "")
|
mock_run.return_value = (0, "1018 passed in 4.23s\nTOTAL 3546 0 100%", "")
|
||||||
cov, tests = collect_coverage_and_tests(tmp_path)
|
cov, tests = collect_coverage_and_tests(tmp_path)
|
||||||
assert cov["message"] == "100%"
|
assert cov["message"] == "100%"
|
||||||
assert tests["message"] == "1018 passing"
|
assert tests["message"] == "1018 passing"
|
||||||
|
|
||||||
@patch("devx.tools.generate_badges.run_command")
|
@patch("devx.tools.generate_badges.run_command")
|
||||||
|
@patch("devx.tools.generate_badges.detect_testpaths", return_value=["tests"])
|
||||||
@patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
|
@patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
|
||||||
def test_returns_unknown_when_no_match(self, mock_target: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
def test_returns_unknown_when_no_match(
|
||||||
|
self, mock_target: MagicMock, mock_testpaths: MagicMock, mock_run: MagicMock, tmp_path: Path
|
||||||
|
) -> None: # type: ignore[no-untyped-def]
|
||||||
mock_run.return_value = (1, "garbled output", "some error")
|
mock_run.return_value = (1, "garbled output", "some error")
|
||||||
cov, tests = collect_coverage_and_tests(tmp_path)
|
cov, tests = collect_coverage_and_tests(tmp_path)
|
||||||
assert cov["message"] == "unknown"
|
assert cov["message"] == "unknown"
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ class TestMain:
|
|||||||
mock_notes: MagicMock,
|
mock_notes: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_tea = MagicMock()
|
mock_tea = MagicMock()
|
||||||
|
mock_tea.list_releases.return_value = []
|
||||||
mock_tea_cls.return_value = mock_tea
|
mock_tea_cls.return_value = mock_tea
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
@@ -187,6 +188,7 @@ class TestMain:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""When no PYPI_TOKEN, publishes to Gitea PyPI registry."""
|
"""When no PYPI_TOKEN, publishes to Gitea PyPI registry."""
|
||||||
mock_tea = MagicMock()
|
mock_tea = MagicMock()
|
||||||
|
mock_tea.list_releases.return_value = []
|
||||||
mock_tea_cls.return_value = mock_tea
|
mock_tea_cls.return_value = mock_tea
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
@@ -209,6 +211,7 @@ class TestMain:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""--registry-url flag publishes to the specified Gitea registry."""
|
"""--registry-url flag publishes to the specified Gitea registry."""
|
||||||
mock_tea = MagicMock()
|
mock_tea = MagicMock()
|
||||||
|
mock_tea.list_releases.return_value = []
|
||||||
mock_tea_cls.return_value = mock_tea
|
mock_tea_cls.return_value = mock_tea
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
@@ -236,6 +239,7 @@ class TestMain:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""DEVX_PYPI_REGISTRY_URL env var sets the registry URL."""
|
"""DEVX_PYPI_REGISTRY_URL env var sets the registry URL."""
|
||||||
mock_tea = MagicMock()
|
mock_tea = MagicMock()
|
||||||
|
mock_tea.list_releases.return_value = []
|
||||||
mock_tea_cls.return_value = mock_tea
|
mock_tea_cls.return_value = mock_tea
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
@@ -256,6 +260,7 @@ class TestMain:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""When no PYPI_TOKEN and no registry URL, skips publish and creates release only."""
|
"""When no PYPI_TOKEN and no registry URL, skips publish and creates release only."""
|
||||||
mock_tea = MagicMock()
|
mock_tea = MagicMock()
|
||||||
|
mock_tea.list_releases.return_value = []
|
||||||
mock_tea_cls.return_value = mock_tea
|
mock_tea_cls.return_value = mock_tea
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--registry-url", ""])
|
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--registry-url", ""])
|
||||||
@@ -307,6 +312,7 @@ class TestMain:
|
|||||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_tea = MagicMock()
|
mock_tea = MagicMock()
|
||||||
|
mock_tea.list_releases.return_value = []
|
||||||
mock_tea.create_release.side_effect = TeaCLIError("server error")
|
mock_tea.create_release.side_effect = TeaCLIError("server error")
|
||||||
mock_tea_cls.return_value = mock_tea
|
mock_tea_cls.return_value = mock_tea
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
@@ -323,6 +329,7 @@ class TestMain:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""--skip-build skips build_package and PyPI publish, only creates Gitea release."""
|
"""--skip-build skips build_package and PyPI publish, only creates Gitea release."""
|
||||||
mock_tea = MagicMock()
|
mock_tea = MagicMock()
|
||||||
|
mock_tea.list_releases.return_value = []
|
||||||
mock_tea_cls.return_value = mock_tea
|
mock_tea_cls.return_value = mock_tea
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
|
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
|
||||||
@@ -330,3 +337,39 @@ class TestMain:
|
|||||||
assert "skip" in result.output.lower()
|
assert "skip" in result.output.lower()
|
||||||
mock_build.assert_not_called()
|
mock_build.assert_not_called()
|
||||||
mock_tea.create_release.assert_called_once()
|
mock_tea.create_release.assert_called_once()
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||||
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||||
|
@patch("devx.ci.publish.TeaCLI")
|
||||||
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
|
@patch("devx.ci.publish.build_package")
|
||||||
|
def test_skips_release_creation_when_already_exists(
|
||||||
|
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""If the Gitea release already exists, skip creation (idempotent)."""
|
||||||
|
mock_tea = MagicMock()
|
||||||
|
mock_tea.list_releases.return_value = [{"tag_name": "v1.0.0"}]
|
||||||
|
mock_tea_cls.return_value = mock_tea
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "already exists" in result.output
|
||||||
|
mock_tea.create_release.assert_not_called()
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||||
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||||
|
@patch("devx.ci.publish.TeaCLI")
|
||||||
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
|
@patch("devx.ci.publish.build_package")
|
||||||
|
def test_proceeds_to_create_when_list_releases_fails(
|
||||||
|
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""If list_releases raises TeaCLIError, proceed to create the release."""
|
||||||
|
mock_tea = MagicMock()
|
||||||
|
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
||||||
|
mock_tea_cls.return_value = mock_tea
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Gitea release v1.0.0 created" in result.output
|
||||||
|
mock_tea.create_release.assert_called_once()
|
||||||
|
|||||||
Reference in New Issue
Block a user