Compare commits

..
Author SHA1 Message Date
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 3f223d5657 test: add unicode preservation and round-trip tests for save_vault_yaml
Add tests verifying allow_unicode=True behavior:
- test_save_preserves_unicode: non-ASCII content (Cyrillic, German
  umlaut, Chinese) is written as UTF-8, not \uXXXX escapes
- test_save_unicode_round_trip: saved unicode data loads back correctly

Also add CHANGELOG entry under [Unreleased] > Fixed.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-13 17:27:20 +02:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ac0ce1c158 fix: pass allow_unicode=True in save_vault_yaml
Matches the pattern already used in infra's scripts/utils/secrets.py:save_yaml.
Prevents corruption of non-ASCII content in vault files.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-13 14:32:53 +02:00
gitea-actions-bot 10de782d93 chore: update badge URLs to commit 642ecf8c [skip ci] 2026-08-12 21:59:23 +00:00
devx-ci-bot bfb3a4d862 release: v0.50.6 [skip ci] 2026-08-12 21:58:35 +00:00
emil User e167be1890 DEVX-159: fix: add GitHub mirror fallback for actionlint and vale downloads 2026-08-12 21:55:52 +00:00
gitea-actions-bot f9cdbec86a chore: update badge URLs to commit 1d748408 [skip ci] 2026-08-12 21:40:58 +00:00
devx-ci-bot 3687f00b83 release: v0.50.5 [skip ci] 2026-08-12 21:39:58 +00:00
emil User ce8e611cc1 DEVX-158: fix: increase download retry attempts and backoff for transient GitHub outages 2026-08-12 21:37:46 +00:00
gitea-actions-bot 544de0bf27 chore: update badge URLs to commit 1f29b164 [skip ci] 2026-08-12 19:33:58 +00:00
emil User f5d3b72a38 DEVX-157: docs: add ADR-0003 and update docs for composite actions 2026-08-12 19:32:28 +00:00
14 changed files with 173 additions and 68 deletions
+16 -3
View File
@@ -51,9 +51,13 @@ jobs:
with:
fetch-depth: 1
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-release
- name: Check if this is a release commit
id: check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.ci.detect_release_commit
@@ -61,6 +65,9 @@ jobs:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && steps.check.outputs.is-release == 'false')
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: |
. .venv/bin/activate
_TOKEN="$CI_GITEA_API_TOKEN"
@@ -72,6 +79,10 @@ jobs:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && steps.check.outputs.is-release == 'false')
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
@@ -115,8 +126,13 @@ jobs:
with:
fetch-depth: 1
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-ci
- name: Clean up old image versions
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.tools.clean_images \
@@ -125,6 +141,3 @@ jobs:
--name oblachno-oss/runner-images/ci-quality \
--name oblachno-oss/runner-images/ci-full \
--keep 2
- uses: ./.gitea/actions/notify-failure
with:
workflow: "build-images/cleanup"
+2 -3
View File
@@ -121,6 +121,7 @@ jobs:
- name: Post approval review
env:
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }}
run: |
@@ -134,6 +135,7 @@ jobs:
--body "Auto-approved: all CI checks passed (validate job)."
- name: Squash merge with task ID
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
HEAD_REF: ${{ github.head_ref }}
@@ -147,6 +149,3 @@ jobs:
"$PR_TITLE" \
"$REPOSITORY" \
"$PR_NUMBER"
- uses: ./.gitea/actions/notify-failure
with:
workflow: "ci/auto-merge"
+9
View File
@@ -111,6 +111,8 @@ jobs:
with:
extras: "release"
- name: Configure git
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
git config user.name "devx-ci-bot"
git config user.email "devx-ci-bot@oblachno.fyi"
@@ -119,12 +121,16 @@ jobs:
- name: Run release
id: release-tag
if: needs.detect-and-configure.outputs.is-release == 'false' && needs.detect-and-configure.outputs.user-facing-changed == 'true'
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release
- name: Build and publish release
if: steps.release-tag.outputs.tag != ''
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
@@ -134,6 +140,8 @@ jobs:
# --- sync-wiki + vikunja (skip on automated/release commits) ---
- name: Sync documentation to wiki
if: needs.detect-and-configure.outputs.is-automated == 'false'
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --verify
@@ -148,6 +156,7 @@ jobs:
# --- badges (always run — even on release commits) ---
- name: Generate and push badges
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: |
. .venv/bin/activate 2>/dev/null || true
+17 -6
View File
@@ -2,18 +2,29 @@
All notable changes to this project will be documented in this file.
## [0.50.6] - 2026-08-12
### Bug Fixes
- Add GitHub mirror fallback for actionlint and vale downloads
## [0.50.5] - 2026-08-12
### Bug Fixes
- Increase download retry attempts and backoff for transient GitHub outages
## [Unreleased]
### Fixed
- `save_vault_yaml`: pass `allow_unicode=True` to `yaml.dump` to prevent
corruption of non-ASCII vault content (matches infra's `save_yaml` pattern)
### Ci
- Add composite actions (setup-env, notify-failure, quality-checks) in `.gitea/actions/`
- Convert ci.yml, post-merge.yml, build-images.yml to use composite actions
- Add notify-failure to ci/auto-merge and build-images/cleanup jobs (previously missing)
- Fix `make workflow-dryrun` failure: use `env` context instead of
`vars`/`secrets` in `container.credentials` (act_runner dry-run
does not populate vars/secrets)
- Remove redundant step-level `CI_GITEA_API_TOKEN`/`CI_GITEA_USERNAME`
env vars (now in workflow-level `env:` block)
## [0.50.4] - 2026-08-12
+9 -9
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/43d0589aeee6d706356f79deef37662c9291b981/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/43d0589aeee6d706356f79deef37662c9291b981/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/43d0589aeee6d706356f79deef37662c9291b981/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/43d0589aeee6d706356f79deef37662c9291b981/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/43d0589aeee6d706356f79deef37662c9291b981/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/43d0589aeee6d706356f79deef37662c9291b981/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/642ecf8c14c883fa864f41b6828d8a1524c13b3e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/642ecf8c14c883fa864f41b6828d8a1524c13b3e/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/642ecf8c14c883fa864f41b6828d8a1524c13b3e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/642ecf8c14c883fa864f41b6828d8a1524c13b3e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/642ecf8c14c883fa864f41b6828d8a1524c13b3e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/642ecf8c14c883fa864f41b6828d8a1524c13b3e/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.50.4",
"devx>=0.50.6",
]
[tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
```
> **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.50.4"`) or use a version constraint
> (for example, `"devx>=0.50.4,<0.51"`).
> `dependencies` (for example, `"devx==0.50.6"`) or use a version constraint
> (for example, `"devx>=0.50.6,<0.51"`).
### Optional extras
+6 -6
View File
@@ -77,17 +77,17 @@ fresh `make setup-image` runs.
- **`ci.yml` validate job**: `setup-env` + `quality-checks` +
`notify-failure`.
- **`ci.yml` auto-merge job**: `setup-env` + `notify-failure` (no
quality checks — auto-merge only runs after validate passes).
- **`ci.yml` auto-merge job**: `setup-env` only (no quality checks,
no notify-failure — auto-merge failure is surfaced by the validate
job's notify-failure).
- **`post-merge.yml` detect-and-configure**: `setup-env` +
`notify-failure`.
- **`post-merge.yml` release-and-maintain**: `setup-env` (with
`extras: "release"`) + `notify-failure`.
- **`build-images.yml` build-and-push**: `notify-failure` only. The
setup steps use `make setup-release` (not `make setup-image`), so
`setup-env` does not apply.
- **`build-images.yml` cleanup**: `notify-failure` only. The setup
step uses `make setup-ci` (not `make setup-image`).
setup steps use `make setup-release` and `make setup-ci` (not
`make setup-image`), so `setup-env` does not apply. The cleanup job
has no notify-failure step (it only runs on build-and-push success).
### Same-Repo Copies (No Cross-Repo References)
+8 -8
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/43d0589aeee6d706356f79deef37662c9291b981/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/43d0589aeee6d706356f79deef37662c9291b981/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/43d0589aeee6d706356f79deef37662c9291b981/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/43d0589aeee6d706356f79deef37662c9291b981/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/43d0589aeee6d706356f79deef37662c9291b981/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/43d0589aeee6d706356f79deef37662c9291b981/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/642ecf8c14c883fa864f41b6828d8a1524c13b3e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/642ecf8c14c883fa864f41b6828d8a1524c13b3e/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/642ecf8c14c883fa864f41b6828d8a1524c13b3e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/642ecf8c14c883fa864f41b6828d8a1524c13b3e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/642ecf8c14c883fa864f41b6828d8a1524c13b3e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/642ecf8c14c883fa864f41b6828d8a1524c13b3e/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.50.4",
"devx>=0.50.6",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.50.4"` or `"devx>=0.50.4,<0.51"`.
Pin a specific version if needed: `"devx==0.50.6"` or `"devx>=0.50.6,<0.51"`.
### Optional extras
-5
View File
@@ -591,8 +591,3 @@ which runs `if: failure()`. This creates a Gitea issue with the workflow
name, run ID, and commit SHA, ensuring failures that would otherwise go
unnoticed in the Actions tab are surfaced as issues. The issue is created
via the tea CLI with a `bug` label if available.
Jobs with `notify-failure`:
- `ci/validate`, `ci/auto-merge`
- `post-merge/detect-and-configure`, `post-merge/release-and-maintain`
- `build-images/build-and-push`, `build-images/cleanup`
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.50.4",
"devx>=0.50.6",
]
[project.optional-dependencies]
dev = [
"devx>=0.50.4",
"devx>=0.50.6",
]
```
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.50.4"
__version__ = "0.50.6"
+39 -24
View File
@@ -50,8 +50,9 @@ logger = logging.getLogger(__name__)
# GitHub releases occasionally drops connections ("Remote end closed
# connection without response"). Retrying with backoff before falling
# through to the next fallback URL makes the build resilient to
# momentary network blips.
MAX_DOWNLOAD_RETRIES = 3
# momentary network blips. 5 attempts with up to 30s between retries
# handles sustained transient outages (observed in CI image builds).
MAX_DOWNLOAD_RETRIES = 5
ACTIONLINT_VERSION = "1.7.12"
@@ -96,7 +97,7 @@ def _download(url: str, dest: Path, *, _sleep=None) -> None:
"""
retrying = Retrying(
stop=stop_after_attempt(MAX_DOWNLOAD_RETRIES),
wait=wait_exponential(multiplier=2, min=2, max=10),
wait=wait_exponential(multiplier=2, min=2, max=30),
retry=retry_if_exception_type((urllib.error.URLError, OSError, ConnectionError)),
before_sleep=before_sleep_log(logger, logging.WARNING),
sleep=_sleep if _sleep is not None else time.sleep,
@@ -130,26 +131,37 @@ def _download_with_fallback(urls: list[str], binary_name: str) -> Path:
raise click.ClickException(f"Failed to download {binary_name} from all URLs: {'; '.join(errors)}")
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
def _download_and_extract_tarball(url: str, binary_name: str, *, fallback_urls: list[str] | None = None) -> Path:
"""Download a tarball, extract the binary, and install it to TARGET_DIR.
Returns the path to the installed binary.
Returns the path to the installed binary. Falls back to ``fallback_urls``
if the primary ``url`` fails all retries.
"""
target_dir = _ensure_target_dir()
dest = target_dir / binary_name
with tempfile.TemporaryDirectory() as tmpdir:
tarball = Path(tmpdir) / "archive.tar.gz"
_download(url, tarball)
with tarfile.open(tarball, "r:gz") as tar:
tar.extractall(tmpdir) # nosec B202
# Find the binary in the extracted tree
extracted = Path(tmpdir).rglob(binary_name)
found = next(extracted, None)
if found is None:
raise click.ClickException(f"Binary {binary_name} not found in archive from {url}")
shutil.copy2(found, dest)
dest.chmod(0o755)
return dest
urls = [url, *(fallback_urls or [])]
errors: list[str] = []
for try_url in urls:
with tempfile.TemporaryDirectory() as tmpdir:
tarball = Path(tmpdir) / "archive.tar.gz"
try:
_download(try_url, tarball)
except Exception as exc: # noqa: BLE001
errors.append(f"{try_url}: {exc}")
click.echo(f" {binary_name}: fallback — {exc}")
continue
with tarfile.open(tarball, "r:gz") as tar:
tar.extractall(tmpdir) # nosec B202
# Find the binary in the extracted tree
extracted = Path(tmpdir).rglob(binary_name)
found = next(extracted, None)
if found is None:
errors.append(f"{try_url}: binary not found in archive")
continue
shutil.copy2(found, dest)
dest.chmod(0o755)
return dest
raise click.ClickException(f"Failed to download {binary_name} from all URLs: {'; '.join(errors)}")
def _download_binary(url: str, binary_name: str) -> Path:
@@ -177,11 +189,12 @@ def install_actionlint() -> bool:
click.echo("actionlint: already installed")
return True
arch = _arch()
url = (
f"https://github.com/rhysd/actionlint/releases/download/"
f"v{ACTIONLINT_VERSION}/actionlint_{ACTIONLINT_VERSION}_linux_{arch}.tar.gz"
path = (
f"rhysd/actionlint/releases/download/v{ACTIONLINT_VERSION}/actionlint_{ACTIONLINT_VERSION}_linux_{arch}.tar.gz"
)
dest = _download_and_extract_tarball(url, "actionlint")
url = f"https://github.com/{path}"
fallback = [f"https://ghproxy.com/{path}"]
dest = _download_and_extract_tarball(url, "actionlint", fallback_urls=fallback)
click.echo(f"actionlint: installed to {dest}")
return True
@@ -275,8 +288,10 @@ def install_vale() -> bool:
return True
machine = platform.machine().lower()
arch = "64-bit" if machine in {"x86_64", "amd64"} else "arm64"
url = f"https://github.com/errata-ai/vale/releases/download/v{VALE_VERSION}/vale_{VALE_VERSION}_Linux_{arch}.tar.gz"
dest = _download_and_extract_tarball(url, "vale")
path = f"errata-ai/vale/releases/download/v{VALE_VERSION}/vale_{VALE_VERSION}_Linux_{arch}.tar.gz"
url = f"https://github.com/{path}"
fallback = [f"https://ghproxy.com/{path}"]
dest = _download_and_extract_tarball(url, "vale", fallback_urls=fallback)
click.echo(f"vale: installed to {dest}")
return True
+1 -1
View File
@@ -102,7 +102,7 @@ def save_vault_yaml(path: Path, data: dict, vault_pass: Path | None = None) -> N
data: Data to serialize.
vault_pass: Path to the vault-password file (optional).
"""
plain = yaml.dump(data, default_flow_style=False, sort_keys=False)
plain = yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True)
with open(path, "w", encoding="utf-8") as f:
f.write(plain)
if vault_pass is not None and vault_pass.exists():
+44
View File
@@ -201,6 +201,50 @@ class TestDownloadAndExtractTarball:
with pytest.raises(ClickException, match="not found in archive"):
install_tools._download_and_extract_tarball("https://example.com/actionlint.tar.gz", "actionlint")
def test_fallback_url_succeeds(self, tmp_path: Path) -> None:
import io
import tarfile
tarball_path = tmp_path / "archive.tar.gz"
binary_content = b"fake binary"
with tarfile.open(tarball_path, "w:gz") as tar:
info = tarfile.TarInfo(name="actionlint")
info.size = len(binary_content)
tar.addfile(info, io.BytesIO(binary_content))
target_dir = tmp_path / "bin"
target_dir.mkdir()
tarball_bytes = tarball_path.read_bytes()
def fake_download(url: str, dest: Path) -> None:
if "primary" in url:
raise OSError("connection refused")
Path(dest).write_bytes(tarball_bytes)
with patch.object(install_tools, "TARGET_DIR", target_dir):
with patch.object(install_tools, "_download", side_effect=fake_download):
result = install_tools._download_and_extract_tarball(
"https://primary.com/actionlint.tar.gz",
"actionlint",
fallback_urls=["https://fallback.com/actionlint.tar.gz"],
)
assert result == target_dir / "actionlint"
assert result.read_bytes() == binary_content
def test_all_urls_fail(self, tmp_path: Path) -> None:
target_dir = tmp_path / "bin"
target_dir.mkdir()
with patch.object(install_tools, "TARGET_DIR", target_dir):
with patch.object(install_tools, "_download", side_effect=OSError("connection refused")):
with pytest.raises(ClickException, match="Failed to download"):
install_tools._download_and_extract_tarball(
"https://primary.com/actionlint.tar.gz",
"actionlint",
fallback_urls=["https://fallback.com/actionlint.tar.gz"],
)
class TestInstallActionlint:
def test_already_installed(self) -> None:
+19
View File
@@ -75,6 +75,25 @@ class TestSaveVaultYaml:
content = f.read_text()
assert "key: value" in content
def test_save_preserves_unicode(self, tmp_path: Path) -> None:
"""allow_unicode=True writes UTF-8 instead of \\uXXXX escapes."""
f = tmp_path / "output.yml"
data = {"name": "Тест", "city": "München", "role": "管理员"}
save_vault_yaml(f, data)
content = f.read_text()
assert "Тест" in content
assert "München" in content
assert "管理员" in content
assert "\\u" not in content
def test_save_unicode_round_trip(self, tmp_path: Path) -> None:
"""Saved unicode data loads back correctly."""
f = tmp_path / "output.yml"
data = {"name": "Тест", "city": "München"}
save_vault_yaml(f, data)
loaded = load_vault_yaml(f)
assert loaded == data
def test_save_with_vault_pass_not_exists(self, tmp_path: Path) -> None:
f = tmp_path / "output.yml"
vp = tmp_path / "nonexistent"