Compare commits

..
Author SHA1 Message Date
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b0f912dd70 ci: add notify-failure to auto-merge and cleanup jobs, clean up redundant env
- Add notify-failure composite action to ci/auto-merge and
  build-images/cleanup jobs (previously missing — failures in these
  jobs went unnoticed)
- Remove redundant step-level CI_GITEA_API_TOKEN/CI_GITEA_USERNAME
  env vars now that workflow-level env: block provides them
- Remove redundant step-level PYTHONPATH env (already in workflow env)
- Update ADR-0003 adoption scope to reflect notify-failure coverage
- Update ci-cd-workflow.md with explicit job list for notify-failure
- Update CHANGELOG with bug fix and cleanup entries

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-12 21:43:46 +02:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 4fc4c5b0d8 docs: add ADR-0003 and update docs for composite actions
New ADR-0003 documenting composite action design rationale and Gitea
1.27 constraints. Updated AGENTS.md, README.md, architecture.md, and
ci-cd-workflow.md with composite action documentation. CHANGELOG entry.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-12 21:27:11 +02:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 64ead7b010 ci: convert workflows to use composite actions
Replace inline setup-env, notify-failure, and quality-checks steps in
ci.yml, post-merge.yml, and build-images.yml with composite action
references. Also fix pre-existing make workflow-dryrun failure by
using env context instead of vars/secrets in container.credentials
(act_runner dry-run does not populate vars/secrets contexts).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-12 21:24:22 +02:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 9b7c008fdc ci: add composite actions for setup-env, notify-failure, quality-checks
Three Gitea composite actions in .gitea/actions/ that eliminate repeated
multi-step sequences across workflows. Each consumer repo gets its own
copy (no cross-repo references). See ADR-0003 for design rationale.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-12 21:21:42 +02:00
14 changed files with 68 additions and 173 deletions
+3 -16
View File
@@ -51,13 +51,9 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-release run: make setup-release
- name: Check if this is a release commit - name: Check if this is a release commit
id: check id: check
env:
PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
python3 -m devx.ci.detect_release_commit python3 -m devx.ci.detect_release_commit
@@ -65,9 +61,6 @@ jobs:
if: >- if: >-
github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && steps.check.outputs.is-release == 'false') (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: | run: |
. .venv/bin/activate . .venv/bin/activate
_TOKEN="$CI_GITEA_API_TOKEN" _TOKEN="$CI_GITEA_API_TOKEN"
@@ -79,10 +72,6 @@ jobs:
if: >- if: >-
github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && steps.check.outputs.is-release == 'false') (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: | run: |
. .venv/bin/activate . .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -126,13 +115,8 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-ci run: make setup-ci
- name: Clean up old image versions - name: Clean up old image versions
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
python3 -m devx.tools.clean_images \ python3 -m devx.tools.clean_images \
@@ -141,3 +125,6 @@ jobs:
--name oblachno-oss/runner-images/ci-quality \ --name oblachno-oss/runner-images/ci-quality \
--name oblachno-oss/runner-images/ci-full \ --name oblachno-oss/runner-images/ci-full \
--keep 2 --keep 2
- uses: ./.gitea/actions/notify-failure
with:
workflow: "build-images/cleanup"
+3 -2
View File
@@ -121,7 +121,6 @@ jobs:
- name: Post approval review - name: Post approval review
env: env:
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }} REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PR_NUMBER: ${{ github.event.number }} PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }} REPOSITORY: ${{ github.repository }}
run: | run: |
@@ -135,7 +134,6 @@ jobs:
--body "Auto-approved: all CI checks passed (validate job)." --body "Auto-approved: all CI checks passed (validate job)."
- name: Squash merge with task ID - name: Squash merge with task ID
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8" DEVX_VIKUNJA_PROJECT_ID: "8"
HEAD_REF: ${{ github.head_ref }} HEAD_REF: ${{ github.head_ref }}
@@ -149,3 +147,6 @@ jobs:
"$PR_TITLE" \ "$PR_TITLE" \
"$REPOSITORY" \ "$REPOSITORY" \
"$PR_NUMBER" "$PR_NUMBER"
- uses: ./.gitea/actions/notify-failure
with:
workflow: "ci/auto-merge"
-9
View File
@@ -111,8 +111,6 @@ jobs:
with: with:
extras: "release" extras: "release"
- name: Configure git - name: Configure git
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: | run: |
git config user.name "devx-ci-bot" git config user.name "devx-ci-bot"
git config user.email "devx-ci-bot@oblachno.fyi" git config user.email "devx-ci-bot@oblachno.fyi"
@@ -121,16 +119,12 @@ jobs:
- name: Run release - name: Run release
id: release-tag id: release-tag
if: needs.detect-and-configure.outputs.is-release == 'false' && needs.detect-and-configure.outputs.user-facing-changed == 'true' 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: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release python3 -m devx.ci.release
- name: Build and publish release - name: Build and publish release
if: steps.release-tag.outputs.tag != '' if: steps.release-tag.outputs.tag != ''
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -140,8 +134,6 @@ jobs:
# --- sync-wiki + vikunja (skip on automated/release commits) --- # --- sync-wiki + vikunja (skip on automated/release commits) ---
- name: Sync documentation to wiki - name: Sync documentation to wiki
if: needs.detect-and-configure.outputs.is-automated == 'false' if: needs.detect-and-configure.outputs.is-automated == 'false'
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --verify python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --verify
@@ -156,7 +148,6 @@ jobs:
# --- badges (always run — even on release commits) --- # --- badges (always run — even on release commits) ---
- name: Generate and push badges - name: Generate and push badges
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PRE_COMMIT_ALLOW_NO_CONFIG: "1" PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
+6 -17
View File
@@ -2,29 +2,18 @@
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.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] ## [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 ### Ci
- Add composite actions (setup-env, notify-failure, quality-checks) in `.gitea/actions/` - 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 - 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 ## [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) [![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) [![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/642ecf8c14c883fa864f41b6828d8a1524c13b3e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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/642ecf8c14c883fa864f41b6828d8a1524c13b3e/tests.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/642ecf8c14c883fa864f41b6828d8a1524c13b3e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![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/642ecf8c14c883fa864f41b6828d8a1524c13b3e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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/642ecf8c14c883fa864f41b6828d8a1524c13b3e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![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/642ecf8c14c883fa864f41b6828d8a1524c13b3e/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/43d0589aeee6d706356f79deef37662c9291b981/python.svg)](https://www.python.org/downloads/)
## Why devx? ## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.50.6", "devx>=0.50.4",
] ]
[tool.pip] [tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
``` ```
> **Note:** If your project requires a specific devx version, pin it in > **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.50.6"`) or use a version constraint > `dependencies` (for example, `"devx==0.50.4"`) or use a version constraint
> (for example, `"devx>=0.50.6,<0.51"`). > (for example, `"devx>=0.50.4,<0.51"`).
### Optional extras ### Optional extras
+6 -6
View File
@@ -77,17 +77,17 @@ fresh `make setup-image` runs.
- **`ci.yml` validate job**: `setup-env` + `quality-checks` + - **`ci.yml` validate job**: `setup-env` + `quality-checks` +
`notify-failure`. `notify-failure`.
- **`ci.yml` auto-merge job**: `setup-env` only (no quality checks, - **`ci.yml` auto-merge job**: `setup-env` + `notify-failure` (no
no notify-failure — auto-merge failure is surfaced by the validate quality checks — auto-merge only runs after validate passes).
job's notify-failure).
- **`post-merge.yml` detect-and-configure**: `setup-env` + - **`post-merge.yml` detect-and-configure**: `setup-env` +
`notify-failure`. `notify-failure`.
- **`post-merge.yml` release-and-maintain**: `setup-env` (with - **`post-merge.yml` release-and-maintain**: `setup-env` (with
`extras: "release"`) + `notify-failure`. `extras: "release"`) + `notify-failure`.
- **`build-images.yml` build-and-push**: `notify-failure` only. The - **`build-images.yml` build-and-push**: `notify-failure` only. The
setup steps use `make setup-release` and `make setup-ci` (not setup steps use `make setup-release` (not `make setup-image`), so
`make setup-image`), so `setup-env` does not apply. The cleanup job `setup-env` does not apply.
has no notify-failure step (it only runs on build-and-push success). - **`build-images.yml` cleanup**: `notify-failure` only. The setup
step uses `make setup-ci` (not `make setup-image`).
### Same-Repo Copies (No Cross-Repo References) ### 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) [![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) [![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/642ecf8c14c883fa864f41b6828d8a1524c13b3e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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/642ecf8c14c883fa864f41b6828d8a1524c13b3e/tests.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/642ecf8c14c883fa864f41b6828d8a1524c13b3e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![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/642ecf8c14c883fa864f41b6828d8a1524c13b3e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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/642ecf8c14c883fa864f41b6828d8a1524c13b3e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![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/642ecf8c14c883fa864f41b6828d8a1524c13b3e/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/43d0589aeee6d706356f79deef37662c9291b981/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.50.6", "devx>=0.50.4",
] ]
[tool.pip] [tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple" extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
``` ```
Pin a specific version if needed: `"devx==0.50.6"` or `"devx>=0.50.6,<0.51"`. Pin a specific version if needed: `"devx==0.50.4"` or `"devx>=0.50.4,<0.51"`.
### Optional extras ### Optional extras
+5
View File
@@ -591,3 +591,8 @@ which runs `if: failure()`. This creates a Gitea issue with the workflow
name, run ID, and commit SHA, ensuring failures that would otherwise go 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 unnoticed in the Actions tab are surfaced as issues. The issue is created
via the tea CLI with a `bug` label if available. 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 ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.50.6", "devx>=0.50.4",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"devx>=0.50.6", "devx>=0.50.4",
] ]
``` ```
+1 -1
View File
@@ -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.50.6" __version__ = "0.50.4"
+24 -39
View File
@@ -50,9 +50,8 @@ logger = logging.getLogger(__name__)
# GitHub releases occasionally drops connections ("Remote end closed # GitHub releases occasionally drops connections ("Remote end closed
# connection without response"). Retrying with backoff before falling # connection without response"). Retrying with backoff before falling
# through to the next fallback URL makes the build resilient to # through to the next fallback URL makes the build resilient to
# momentary network blips. 5 attempts with up to 30s between retries # momentary network blips.
# handles sustained transient outages (observed in CI image builds). MAX_DOWNLOAD_RETRIES = 3
MAX_DOWNLOAD_RETRIES = 5
ACTIONLINT_VERSION = "1.7.12" ACTIONLINT_VERSION = "1.7.12"
@@ -97,7 +96,7 @@ def _download(url: str, dest: Path, *, _sleep=None) -> None:
""" """
retrying = Retrying( retrying = Retrying(
stop=stop_after_attempt(MAX_DOWNLOAD_RETRIES), stop=stop_after_attempt(MAX_DOWNLOAD_RETRIES),
wait=wait_exponential(multiplier=2, min=2, max=30), wait=wait_exponential(multiplier=2, min=2, max=10),
retry=retry_if_exception_type((urllib.error.URLError, OSError, ConnectionError)), retry=retry_if_exception_type((urllib.error.URLError, OSError, ConnectionError)),
before_sleep=before_sleep_log(logger, logging.WARNING), before_sleep=before_sleep_log(logger, logging.WARNING),
sleep=_sleep if _sleep is not None else time.sleep, sleep=_sleep if _sleep is not None else time.sleep,
@@ -131,37 +130,26 @@ 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)}") raise click.ClickException(f"Failed to download {binary_name} from all URLs: {'; '.join(errors)}")
def _download_and_extract_tarball(url: str, binary_name: str, *, fallback_urls: list[str] | None = None) -> Path: def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
"""Download a tarball, extract the binary, and install it to TARGET_DIR. """Download a tarball, extract the binary, and install it to TARGET_DIR.
Returns the path to the installed binary. Falls back to ``fallback_urls`` Returns the path to the installed binary.
if the primary ``url`` fails all retries.
""" """
target_dir = _ensure_target_dir() target_dir = _ensure_target_dir()
dest = target_dir / binary_name dest = target_dir / binary_name
urls = [url, *(fallback_urls or [])] with tempfile.TemporaryDirectory() as tmpdir:
errors: list[str] = [] tarball = Path(tmpdir) / "archive.tar.gz"
for try_url in urls: _download(url, tarball)
with tempfile.TemporaryDirectory() as tmpdir: with tarfile.open(tarball, "r:gz") as tar:
tarball = Path(tmpdir) / "archive.tar.gz" tar.extractall(tmpdir) # nosec B202
try: # Find the binary in the extracted tree
_download(try_url, tarball) extracted = Path(tmpdir).rglob(binary_name)
except Exception as exc: # noqa: BLE001 found = next(extracted, None)
errors.append(f"{try_url}: {exc}") if found is None:
click.echo(f" {binary_name}: fallback — {exc}") raise click.ClickException(f"Binary {binary_name} not found in archive from {url}")
continue shutil.copy2(found, dest)
with tarfile.open(tarball, "r:gz") as tar: dest.chmod(0o755)
tar.extractall(tmpdir) # nosec B202 return dest
# 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: def _download_binary(url: str, binary_name: str) -> Path:
@@ -189,12 +177,11 @@ def install_actionlint() -> bool:
click.echo("actionlint: already installed") click.echo("actionlint: already installed")
return True return True
arch = _arch() arch = _arch()
path = ( url = (
f"rhysd/actionlint/releases/download/v{ACTIONLINT_VERSION}/actionlint_{ACTIONLINT_VERSION}_linux_{arch}.tar.gz" f"https://github.com/rhysd/actionlint/releases/download/"
f"v{ACTIONLINT_VERSION}/actionlint_{ACTIONLINT_VERSION}_linux_{arch}.tar.gz"
) )
url = f"https://github.com/{path}" dest = _download_and_extract_tarball(url, "actionlint")
fallback = [f"https://ghproxy.com/{path}"]
dest = _download_and_extract_tarball(url, "actionlint", fallback_urls=fallback)
click.echo(f"actionlint: installed to {dest}") click.echo(f"actionlint: installed to {dest}")
return True return True
@@ -288,10 +275,8 @@ def install_vale() -> bool:
return True return True
machine = platform.machine().lower() machine = platform.machine().lower()
arch = "64-bit" if machine in {"x86_64", "amd64"} else "arm64" arch = "64-bit" if machine in {"x86_64", "amd64"} else "arm64"
path = f"errata-ai/vale/releases/download/v{VALE_VERSION}/vale_{VALE_VERSION}_Linux_{arch}.tar.gz" url = f"https://github.com/errata-ai/vale/releases/download/v{VALE_VERSION}/vale_{VALE_VERSION}_Linux_{arch}.tar.gz"
url = f"https://github.com/{path}" dest = _download_and_extract_tarball(url, "vale")
fallback = [f"https://ghproxy.com/{path}"]
dest = _download_and_extract_tarball(url, "vale", fallback_urls=fallback)
click.echo(f"vale: installed to {dest}") click.echo(f"vale: installed to {dest}")
return True 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. data: Data to serialize.
vault_pass: Path to the vault-password file (optional). vault_pass: Path to the vault-password file (optional).
""" """
plain = yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True) plain = yaml.dump(data, default_flow_style=False, sort_keys=False)
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
f.write(plain) f.write(plain)
if vault_pass is not None and vault_pass.exists(): if vault_pass is not None and vault_pass.exists():
-44
View File
@@ -201,50 +201,6 @@ class TestDownloadAndExtractTarball:
with pytest.raises(ClickException, match="not found in archive"): with pytest.raises(ClickException, match="not found in archive"):
install_tools._download_and_extract_tarball("https://example.com/actionlint.tar.gz", "actionlint") 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: class TestInstallActionlint:
def test_already_installed(self) -> None: def test_already_installed(self) -> None:
-19
View File
@@ -75,25 +75,6 @@ class TestSaveVaultYaml:
content = f.read_text() content = f.read_text()
assert "key: value" in content 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: def test_save_with_vault_pass_not_exists(self, tmp_path: Path) -> None:
f = tmp_path / "output.yml" f = tmp_path / "output.yml"
vp = tmp_path / "nonexistent" vp = tmp_path / "nonexistent"