Compare commits

..
15 Commits
Author SHA1 Message Date
gitea-actions-bot 8edf2be14a chore: update badge URLs to commit e275ba3c [skip ci] 2026-08-12 23:40:36 +00:00
devx-ci-bot 029fb65216 release: v0.50.7 [skip ci] 2026-08-12 23:39:52 +00:00
emil 84df8038df DEVX-160: refactor: remove deprecated devx.ci.discover_runners wrapper
Post-merge / detect-and-configure (push) Successful in 21s
Post-merge / release-and-maintain (push) Successful in 1m14s
2026-08-12 23:38:56 +00: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 e167be1890 DEVX-159: fix: add GitHub mirror fallback for actionlint and vale downloads
Post-merge / detect-and-configure (push) Successful in 20s
Post-merge / release-and-maintain (push) Successful in 3m1s
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 ce8e611cc1 DEVX-158: fix: increase download retry attempts and backoff for transient GitHub outages
Post-merge / detect-and-configure (push) Successful in 1m38s
Post-merge / release-and-maintain (push) Successful in 1m30s
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 f5d3b72a38 DEVX-157: docs: add ADR-0003 and update docs for composite actions
Post-merge / detect-and-configure (push) Successful in 13s
Post-merge / release-and-maintain (push) Successful in 1m7s
2026-08-12 19:32:28 +00:00
gitea-actions-bot ad98d76c1f chore: update badge URLs to commit 43d0589a [skip ci] 2026-08-12 18:17:06 +00:00
devx-ci-bot 6aa1933dbd release: v0.50.4 [skip ci] 2026-08-12 18:16:14 +00:00
emil 08f38f3635 DEVX-156: fix: add tenacity retry to install_tools._download for transient network failures
Post-merge / detect-and-configure (push) Successful in 16s
Post-merge / release-and-maintain (push) Successful in 1m21s
2026-08-12 18:15:25 +00:00
gitea-actions-bot fc5e4634dd chore: update badge URLs to commit 1b620bc1 [skip ci] 2026-08-12 17:39:15 +00:00
22 changed files with 754 additions and 285 deletions
+47
View File
@@ -0,0 +1,47 @@
name: 'Notify on failure'
description: 'Create a Gitea issue when a CI workflow fails (calls devx.ci.notify_failure)'
# Composite action for the common "Notify on failure" step pattern.
# Replaces the repeated inline:
# - name: Notify on failure
# if: failure()
# 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.notify_failure \
# --repo "${{ github.repository }}" \
# --run-id "${{ github.run_id }}" \
# --workflow "ci/validate" \
# --commit "${{ github.sha }}" \
# --auto-login
#
# Gitea 1.27 notes:
# - `if: failure()` is evaluated in the calling workflow's context and
# propagates correctly to composite action steps.
# - `secrets` are not accessible here; the calling workflow's top-level
# `env:` CI_GITEA_API_TOKEN is used via `${{ env.* }}`.
inputs:
workflow:
description: 'Workflow/job name used in the Gitea issue title (e.g., ci/validate)'
required: true
runs:
using: 'composite'
steps:
- name: Notify on failure
if: failure()
shell: bash
env:
CI_GITEA_API_TOKEN: ${{ env.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "${{ inputs.workflow }}" \
--commit "${{ github.sha }}" \
--auto-login
+89
View File
@@ -0,0 +1,89 @@
name: 'Quality checks'
description: 'Run lint, unit tests with coverage, test speed, docs, translations, and security scan'
# Composite action for the 6-step quality check sequence used by the
# devx validate job. Replaces the inline block:
# - Lint all
# - Unit tests with 100% coverage
# - Check unit test speed
# - Documentation gate (coverage + stale refs + lint + version refs + prose)
# - Translation completeness check
# - Dependency security scan
#
# Each step activates the venv defensively (`. .venv/bin/activate 2>/dev/null
# || true`) so the action works whether or not the setup step created a
# venv at the repo root (pre-built CI images symlink /opt/venv to .venv).
#
# Gitea 1.27 notes:
# - Every `run` step needs explicit `shell:`.
# - Inputs are string-typed; numeric thresholds are passed through as
# strings to `devx.tools.check_test_speed`.
inputs:
package:
description: 'Package name for doc version checks (e.g., devx, grm). Empty = no DEVX_DOC_VERSIONS_PKG override.'
required: false
default: ''
test-speed-max:
description: 'Max total test seconds (passed to check_test_speed --max-seconds)'
required: false
default: '15'
test-speed-max-single:
description: 'Max single test seconds (passed to check_test_speed --max-single-seconds)'
required: false
default: '0.5'
translations-file:
description: 'Path to translations.json (empty = default location src/devx/translations.json)'
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Lint all
shell: bash
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
make lint-all
- name: Unit tests with 100% coverage
shell: bash
run: |
. .venv/bin/activate 2>/dev/null || true
make pytest-cov
- name: Check unit test speed
shell: bash
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed \
--max-seconds "${{ inputs.test-speed-max }}" \
--max-single-seconds "${{ inputs.test-speed-max-single }}"
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
shell: bash
env:
DEVX_DOC_COVERAGE_STRICT: "1"
DEVX_VALE_LEVEL: warning
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
if [ -n "${{ inputs.package }}" ]; then
export DEVX_DOC_VERSIONS_PKG="${{ inputs.package }}"
fi
make devx-docs-check
- name: Translation completeness check
shell: bash
run: |
. .venv/bin/activate 2>/dev/null || true
if [ -n "${{ inputs.translations-file }}" ]; then
python3 -m devx.ci.check_translations --translations "${{ inputs.translations-file }}"
else
python3 -m devx.ci.check_translations
fi
- name: Dependency security scan
shell: bash
run: |
. .venv/bin/activate 2>/dev/null || true
# Install pip in venv if missing (needed by pip-audit)
.venv/bin/python -m ensurepip 2>/dev/null || true
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
pip-audit --desc --skip-editable 2>&1 || true
+37
View File
@@ -0,0 +1,37 @@
name: 'Set up environment'
description: 'Set up CI environment with venv and PATH (calls make setup-image)'
# Composite action for the common "Set up environment" step pattern.
# Replaces the repeated inline:
# - name: Set up environment
# env:
# CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
# run: make setup-image
#
# Gitea 1.27 notes:
# - Every `run` step needs explicit `shell:`.
# - Composite actions cannot access `secrets` directly; they read from
# the `env:` context which the calling workflow must populate.
# - The calling workflow's top-level `env:` block (CI_GITEA_API_TOKEN,
# CI_GITEA_USERNAME) is visible here via `${{ env.* }}`.
inputs:
extras:
description: 'Extra pip install groups passed to make setup-image (e.g., ci,lint,release)'
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Set up environment
shell: bash
env:
CI_GITEA_API_TOKEN: ${{ env.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ env.CI_GITEA_USERNAME }}
run: |
if [ -n "${{ inputs.extras }}" ]; then
make setup-image EXTRAS="${{ inputs.extras }}"
else
make setup-image
fi
+13 -18
View File
@@ -29,14 +29,20 @@ concurrency:
group: build-images
cancel-in-progress: false
env:
PIP_BREAK_SYSTEM_PACKAGES: "1"
PYTHONPATH: src
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs:
build-and-push:
runs-on: docker
container:
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
credentials:
username: ${{ vars.CI_GITEA_USERNAME }}
password: ${{ secrets.CI_GITEA_API_TOKEN }}
username: ${{ env.CI_GITEA_USERNAME }}
password: ${{ env.CI_GITEA_API_TOKEN }}
timeout-minutes: 30
outputs:
is-release: ${{ steps.check.outputs.is-release }}
@@ -101,20 +107,9 @@ jobs:
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "build-images/build-and-push" \
--commit "${{ github.sha }}" \
--auto-login
- uses: ./.gitea/actions/notify-failure
with:
workflow: "build-images/build-and-push"
cleanup:
needs: [build-and-push]
@@ -123,8 +118,8 @@ jobs:
container:
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
credentials:
username: ${{ vars.CI_GITEA_USERNAME }}
password: ${{ secrets.CI_GITEA_API_TOKEN }}
username: ${{ env.CI_GITEA_USERNAME }}
password: ${{ env.CI_GITEA_API_TOKEN }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
+14 -58
View File
@@ -21,8 +21,8 @@ jobs:
container:
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
credentials:
username: ${{ vars.CI_GITEA_USERNAME }}
password: ${{ secrets.CI_GITEA_API_TOKEN }}
username: ${{ env.CI_GITEA_USERNAME }}
password: ${{ env.CI_GITEA_API_TOKEN }}
timeout-minutes: 15
defaults:
run:
@@ -33,43 +33,12 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
# --- quality steps ---
- name: Lint all
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
make lint-all
- name: Unit tests with 100% coverage
run: |
. .venv/bin/activate 2>/dev/null || true
make pytest-cov
- name: Check unit test speed
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 15 --max-single-seconds 0.5
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
env:
DEVX_DOC_COVERAGE_STRICT: "1"
DEVX_VALE_LEVEL: warning
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
make devx-docs-check
- name: Translation completeness check
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations
- name: Dependency security scan
run: |
. .venv/bin/activate 2>/dev/null || true
# Install pip in venv if missing (needed by pip-audit)
.venv/bin/python -m ensurepip 2>/dev/null || true
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
pip-audit --desc --skip-editable 2>&1 || true
- uses: ./.gitea/actions/setup-env
- uses: ./.gitea/actions/quality-checks
with:
package: devx
test-speed-max: "15"
test-speed-max-single: "0.5"
- name: Workflow dry-run validation
run: |
. .venv/bin/activate 2>/dev/null || true
@@ -121,19 +90,9 @@ jobs:
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run
- name: Notify on failure
if: failure()
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.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "ci/validate" \
--commit "${{ github.sha }}" \
--auto-login
- uses: ./.gitea/actions/notify-failure
with:
workflow: "ci/validate"
auto-merge:
# Auto-merge runs after validate passes. It reads the task ID
@@ -147,8 +106,8 @@ jobs:
container:
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
credentials:
username: ${{ vars.CI_GITEA_USERNAME }}
password: ${{ secrets.CI_GITEA_API_TOKEN }}
username: ${{ env.CI_GITEA_USERNAME }}
password: ${{ env.CI_GITEA_API_TOKEN }}
timeout-minutes: 10
defaults:
run:
@@ -158,10 +117,7 @@ jobs:
with:
fetch-depth: 0
token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
- uses: ./.gitea/actions/setup-env
- name: Post approval review
env:
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
+14 -38
View File
@@ -38,8 +38,8 @@ jobs:
container:
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
credentials:
username: ${{ vars.CI_GITEA_USERNAME }}
password: ${{ secrets.CI_GITEA_API_TOKEN }}
username: ${{ env.CI_GITEA_USERNAME }}
password: ${{ env.CI_GITEA_API_TOKEN }}
timeout-minutes: 10
defaults:
run:
@@ -52,10 +52,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
- uses: ./.gitea/actions/setup-env
- name: Ensure branch protection and labels
env:
DEVX_REPO_NAME: devx
@@ -85,19 +82,9 @@ jobs:
--base "HEAD~1" \
--head "HEAD" \
--github-output
- name: Notify on failure
if: failure()
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.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/detect-and-configure" \
--commit "${{ github.sha }}" \
--auto-login
- uses: ./.gitea/actions/notify-failure
with:
workflow: "post-merge/detect-and-configure"
release-and-maintain:
needs: [detect-and-configure]
@@ -106,8 +93,8 @@ jobs:
container:
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
credentials:
username: ${{ vars.CI_GITEA_USERNAME }}
password: ${{ secrets.CI_GITEA_API_TOKEN }}
username: ${{ env.CI_GITEA_USERNAME }}
password: ${{ env.CI_GITEA_API_TOKEN }}
timeout-minutes: 15
outputs:
tag: ${{ steps.release-tag.outputs.tag }}
@@ -120,10 +107,9 @@ jobs:
fetch-depth: 0
ref: master
token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image EXTRAS=release
- uses: ./.gitea/actions/setup-env
with:
extras: "release"
- name: Configure git
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
@@ -179,16 +165,6 @@ jobs:
git fetch origin master
git reset --hard origin/master
python3 -m devx.ci.push_badges
- name: Notify on failure
if: failure()
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.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/release-and-maintain" \
--commit "${{ github.sha }}" \
--auto-login
- uses: ./.gitea/actions/notify-failure
with:
workflow: "post-merge/release-and-maintain"
+69 -2
View File
@@ -58,6 +58,74 @@ The pre-commit hook runs actionlint automatically when workflow files change.
The CI `validate` job runs `make setup-image` then `make lint-all`.
CI also runs a best-effort `make workflow-dryrun` step (skipped if act_runner is not installed in the CI Docker image).
## Composite Actions (`.gitea/actions/`)
Reusable Gitea composite actions eliminate repeated multi-step sequences
across workflows. Each action lives in its own directory under
`.gitea/actions/<name>/action.yml` and is referenced via
`uses: ./.gitea/actions/<name>`.
### Available Composite Actions
| Action | Purpose | Inputs |
|--------|---------|--------|
| `setup-env` | Run `make setup-image` (with optional `EXTRAS=`) | `extras` (default: `""`) |
| `notify-failure` | Create a Gitea issue on job failure via `devx.ci.notify_failure` | `workflow` (required) |
| `quality-checks` | 6-step quality sequence: lint, tests, speed, docs, translations, security | `package`, `test-speed-max`, `test-speed-max-single`, `translations-file` |
### Gitea 1.27 Constraints
- Every `run` step in a composite action MUST have explicit `shell:`.
- Composite actions CANNOT access `secrets` directly. They read from
the calling workflow's `env:` context (for example, `${{ env.CI_GITEA_API_TOKEN }}`).
The calling workflow's top-level `env:` block must define the required
env vars.
- `if: failure()` in a composite action step is evaluated in the
calling workflow's job-status context.
### Usage Pattern
```yaml
jobs:
validate:
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/setup-env
- uses: ./.gitea/actions/quality-checks
with:
package: devx
- uses: ./.gitea/actions/notify-failure
with:
workflow: "ci/validate"
```
### Same-Repo Copies (No Cross-Repo References)
Each repo (`devx`, `grm`, `infra`) gets its own copy of the composite
actions under its `.gitea/actions/` directory. There is no
`uses: oblachno-oss/devx/.gitea/actions/...@vX.Y.Z` reference. This
avoids a single-point-of-failure where a bad devx master commit would
break all repos' CI simultaneously. See ADR-0003 for the full
rationale.
### When NOT to Use Composite Actions
- **`make setup-release` / `make setup-ci`**: the `setup-env` action
only wraps `make setup-image`. Workflows that use other setup targets
(for example, `build-images.yml` uses `make setup-release`) keep the inline
setup step.
- **Deploy-specific setup**: infra deploy workflows have additional
steps (`install-collections`, `setup-vault`, `setup_ssh_key`) that
are NOT part of the common setup. The `setup-env` action only
replaces the `make setup-image` step; deploy-specific steps stay
inline.
- **Custom notification**: `security-scan.yml` uses a Mattermost
webhook, not `devx.ci.notify_failure`. The `notify-failure` action
does not apply.
## Architecture
devx is a reusable Python package providing development and CI/CD tools for oblachno-oss projects.
@@ -99,7 +167,6 @@ src/devx/
│ ├── cancel_superseded_runs.py # Cancel in-flight CI runs for the same PR branch
│ ├── check_workflow_artifact_deps.py # Verify artifact download jobs depend on upload jobs
│ ├── check_workflow_tofu_init.py # Verify tofu-state jobs have a tofu-init step
│ ├── discover_runners.py # Deprecated wrapper → molecule/discover_runners
│ └── wait_for_checks.py # Poll Gitea Actions for job completion (replaces inline shell polling)
├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── setup.py # Environment setup (venv, deps, hooks)
@@ -152,7 +219,7 @@ src/devx/
│ ├── ui.py # say() — unified click.echo + logging output
│ └── jinja.py # Jinja2 environment helpers + Ansible-compatible filters
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
├── discover_runners.py # Dynamic Gitea runner discovery (canonical; ci/discover_runners is a deprecated wrapper)
├── discover_runners.py # Dynamic Gitea runner discovery
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
├── molecule_all.py # Run all molecule scenarios locally
+39
View File
@@ -2,6 +2,45 @@
All notable changes to this project will be documented in this file.
## [0.50.7] - 2026-08-12
### Refactor
- Remove deprecated devx.ci.discover_runners wrapper
## [Unreleased]
### Breaking Changes
- Remove deprecated `devx.ci.discover_runners` wrapper. All workflows
now use `devx.molecule.discover_runners` directly. The `devx ci
discover-runners` CLI subcommand has also been removed.
## [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]
### 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
## [0.50.4] - 2026-08-12
### Bug Fixes
- Add tenacity retry to install_tools._download for transient network failures
## [0.50.3] - 2026-08-12
### Refactor
+21 -10
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/86408e58c8e739016a968a190433a49908519c6d/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/86408e58c8e739016a968a190433a49908519c6d/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/86408e58c8e739016a968a190433a49908519c6d/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/86408e58c8e739016a968a190433a49908519c6d/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/86408e58c8e739016a968a190433a49908519c6d/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/86408e58c8e739016a968a190433a49908519c6d/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e275ba3c2917f751b075ba2f300f4fc00758c0c0/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e275ba3c2917f751b075ba2f300f4fc00758c0c0/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e275ba3c2917f751b075ba2f300f4fc00758c0c0/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e275ba3c2917f751b075ba2f300f4fc00758c0c0/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e275ba3c2917f751b075ba2f300f4fc00758c0c0/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e275ba3c2917f751b075ba2f300f4fc00758c0c0/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.3",
"devx>=0.50.7",
]
[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.3"`) or use a version constraint
> (for example, `"devx>=0.50.3,<0.51"`).
> `dependencies` (for example, `"devx==0.50.7"`) or use a version constraint
> (for example, `"devx>=0.50.7,<0.51"`).
### Optional extras
@@ -172,7 +172,7 @@ python -m devx.ci.notify_failure --repo oblachno-oss/devx --run-id 123 \
--workflow ci --commit abc123 --auto-login
# Discover available Gitea Actions runners
python -m devx.ci.discover_runners --owner oblachno-oss --repo devx --indices
python -m devx.molecule.discover_runners --owner oblachno-oss --repo devx --indices
# Distribute files across parallel runners (round-robin)
python -m devx.ci.distribute_files --pattern "tests/integration/test_*.py" \
@@ -445,6 +445,17 @@ src/devx/
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
```
### Composite Actions (`.gitea/actions/`)
Reusable Gitea composite actions for CI workflow steps:
- `setup-env` — runs `make setup-image` (with optional `EXTRAS=`)
- `notify-failure` — creates a Gitea issue on job failure
- `quality-checks` — 6-step quality gate (lint, tests, speed, docs, translations, security)
Each consumer repo gets its own copy (no cross-repo references). See
ADR-0003 for the design rationale.
### Design principles
- **Self-contained package**`src/devx/` never imports from scripts outside the package
@@ -81,17 +81,13 @@ Extract the inline polling logic into `devx.ci.wait_for_checks`:
This replaces the inline shell polling in `grm` `ci.yml` with a
reusable, testable Python module.
### 3. Deprecated `ci/discover_runners` Wrapper
### 3. Removed `ci/discover_runners` Wrapper
Merge the `ci/discover_runners` implementation (with its better error
Merged the `ci/discover_runners` implementation (with its better error
logging) into `molecule/discover_runners` as the canonical version.
Make `ci/discover_runners` a deprecated wrapper that:
- Re-exports all public symbols from `molecule.discover_runners`
- Emits a `DeprecationWarning` when run as `__main__`
- Preserves backward compatibility for existing workflow references
New code should import from `devx.molecule.discover_runners` directly.
The `ci/discover_runners` wrapper was deprecated in Phase 1c and
**removed in Phase 2d** (DEVX-160). All workflows now use
`devx.molecule.discover_runners` directly.
## Consequences
+155
View File
@@ -0,0 +1,155 @@
# ADR-0003: Composite Actions for CI Workflow Reuse
Date: 2026-08-12
Status: Accepted
## Context
The devx repository's Gitea Actions workflows (`.gitea/workflows/ci.yml`,
`post-merge.yml`, `build-images.yml`) repeated multi-step
sequences across jobs:
1. **Set up environment**`make setup-image` (optionally with `EXTRAS=`).
Appeared verbatim in 4 jobs across `ci.yml` and `post-merge.yml`, each
with the same `CI_GITEA_API_TOKEN` env wiring.
2. **Notify on failure**`python3 -m devx.ci.notify_failure ... --auto-login`
with venv activation, PATH export, and 5 fixed CLI args. Appeared in
4 jobs (`ci/validate`, `post-merge/detect-and-configure`,
`post-merge/release-and-maintain`, `build-images/build-and-push`),
each differing only in the `--workflow` string.
3. **Quality checks** — a 6-step sequence (lint-all, pytest-cov,
check-test-speed, devx-docs-check, check-translations, pip-audit)
with venv activation boilerplate on every step. Appeared once in
`ci.yml` validate job, but the same sequence is needed by `grm` and
`infra` (Phase 2b/2c of the cross-repo refactoring plan).
This duplication had the following costs:
- **Drift risk**: a fix to notify-failure (for example, new flag,
different env var) had to be applied to 4 places; missing one caused
inconsistent failure notifications.
- **Workflow YAML noise**: the 6-step quality block obscured the
validate job's actual structure (detect-changes, pr-review,
release-dry-run).
- **Cross-repo reuse blocked**: `grm` and `infra` could not adopt the
same quality-checks sequence without copy-pasting the inline steps,
which would amplify the drift problem across 3 repos.
- **Gitea 1.27 constraints**: every `run` step needs explicit `shell:`;
composite actions cannot access `secrets` directly (only `env:`).
These constraints had to be re-discovered and re-applied per step.
## Decision
Introduce three Gitea composite actions in `.gitea/actions/`:
### 1. `setup-env/action.yml`
Wraps the `make setup-image` call. Single input `extras` (default empty)
forwarded to `make setup-image EXTRAS=`. Reads `CI_GITEA_API_TOKEN` and
`CI_GITEA_USERNAME` from the calling workflow's `env:` context.
### 2. `notify-failure/action.yml`
Wraps the `devx.ci.notify_failure` invocation. Single required input
`workflow` (the workflow/job name for the Gitea issue title). Step is
gated by `if: failure()` so it only runs on job failure. Reads
`CI_GITEA_API_TOKEN` from the calling workflow's `env:` context.
### 3. `quality-checks/action.yml`
Wraps the 6-step quality sequence. Inputs:
- `package` (default empty) — sets `DEVX_DOC_VERSIONS_PKG` for doc
version checks (for example, `devx`, `grm`).
- `test-speed-max` (default `15`) — total test seconds threshold.
- `test-speed-max-single` (default `0.5`) — per-test seconds threshold.
- `translations-file` (default empty) — path to `translations.json`
for repos whose translations live outside `src/devx/`.
Each step activates the venv defensively
(`. .venv/bin/activate 2>/dev/null || true`) so the action works with
both pre-built CI images (which symlink `/opt/venv` to `.venv`) and
fresh `make setup-image` runs.
### Adoption Scope
- **`ci.yml` validate job**: `setup-env` + `quality-checks` +
`notify-failure`.
- **`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` 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)
Each consumer repo (`devx`, `grm`, `infra`) gets its own copy of the
composite actions under its `.gitea/actions/` directory. There is no
`uses: oblachno-oss/devx/.gitea/actions/...@vX.Y.Z` reference.
This avoids a single-point-of-failure where a bad `devx` master commit
would break all three repos' CI simultaneously. The cost is three
copies of ~30 lines of YAML each, updated manually when a composite
action changes. Given the stability of these patterns (the inline
versions were unchanged for months), this cost is acceptable.
## Consequences
### Positive
- **Workflow YAML is shorter and clearer**: the validate job's
quality block collapses from 32 lines to 5 lines. The intent
(`uses: ./.gitea/actions/quality-checks`) is more legible than
6 individually wrapped steps.
- **Drift eliminated**: a change to notify-failure (new flag, different
env var) is applied in one file. All 4 calling sites pick it up.
- **Cross-repo reuse enabled**: Phase 2b (`grm`) and Phase 2c (`infra`)
copy the same `action.yml` files and adopt the same `uses:` pattern.
The quality-checks sequence is now portable.
- **Gitea 1.27 constraints centralized**: the `shell: bash` and
`env:` (not `secrets`) patterns are encoded once per action, not
re-derived per step.
- **No release triggered**: changes to `.gitea/**` are classified as
workflow-only by `devx.ci.classify_changes`. Phase 2a does not
produce a new devx version. `grm`/`infra` bump to the Phase 1
release (v0.50.4), not a Phase 2a version.
### Negative
- **Three copies of each action**: when a composite action changes,
the change must be applied to `devx`, `grm`, and `infra`
independently. This is intentional (see Same-Repo Copies preceding)
but is a maintenance cost.
- **Composite action debugging is harder**: Gitea's log output for
composite action steps is nested under the action name. Finding the
failing step requires reading one more level of indentation.
- **`env:` propagation is implicit**: the calling workflow's top-level
`env:` block must define `CI_GITEA_API_TOKEN` for the composite
action to read it. A workflow that omits this will see an empty
token at runtime, not at lint time. actionlint does not catch this.
- **`quality-checks` is devx-shaped**: the `package` and
`translations-file` inputs exist because consumer repos (for example,
`grm`) have translations files outside the default
`src/devx/translations.json` location and need doc version checks
targeting their own package name.
A repo with a different translations path or package layout would need
a new input or a different action. This is acceptable for the current
3-repo scope.
### Neutral
- **`if: failure()` is preserved**: the `notify-failure` composite
action's step has `if: failure()`, which is evaluated in the
calling workflow's job-status context. This is the standard Gitea
Actions pattern for post-failure notification.
- **Venv activation is defensive**: `. .venv/bin/activate 2>/dev/null
|| true` does not fail if the venv is missing (pre-built image path)
or already active. This matches the inline pattern's behavior.
+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/86408e58c8e739016a968a190433a49908519c6d/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/86408e58c8e739016a968a190433a49908519c6d/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/86408e58c8e739016a968a190433a49908519c6d/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/86408e58c8e739016a968a190433a49908519c6d/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/86408e58c8e739016a968a190433a49908519c6d/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/86408e58c8e739016a968a190433a49908519c6d/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e275ba3c2917f751b075ba2f300f4fc00758c0c0/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e275ba3c2917f751b075ba2f300f4fc00758c0c0/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e275ba3c2917f751b075ba2f300f4fc00758c0c0/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e275ba3c2917f751b075ba2f300f4fc00758c0c0/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e275ba3c2917f751b075ba2f300f4fc00758c0c0/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e275ba3c2917f751b075ba2f300f4fc00758c0c0/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.3",
"devx>=0.50.7",
]
[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.3"` or `"devx>=0.50.3,<0.51"`.
Pin a specific version if needed: `"devx==0.50.7"` or `"devx>=0.50.7,<0.51"`.
### Optional extras
+19 -1
View File
@@ -409,7 +409,7 @@ Intended for local development; CI uses the parallel matrix instead.
### `molecule/discover_runners.py`
Discovers available Gitea Actions runners for molecule tests. This is the
canonical implementation; `devx.ci.discover_runners` is a deprecated wrapper
canonical implementation (formerly `devx.ci.discover_runners`, removed in v0.51.0)
that re-exports from this module. Queries runners at repository,
organization, and instance (administrator) levels, with warnings logged
to stderr on non-200 responses (except 403 on instance-level, which is
@@ -453,6 +453,24 @@ v2 failures. Supports loading custom platforms from a JSON file.
- **Secrets via environment** — secrets are passed via environment variables,
never on the command line.
## Composite Actions (`.gitea/actions/`)
Reusable Gitea composite actions eliminate repeated multi-step sequences
across workflows. Each action lives in `.gitea/actions/<name>/action.yml`
and is referenced via `uses: ./.gitea/actions/<name>`.
| Action | Purpose |
|--------|---------|
| `setup-env` | Run `make setup-image` (with optional `EXTRAS=`) |
| `notify-failure` | Create a Gitea issue on job failure via `devx.ci.notify_failure` |
| `quality-checks` | 6-step quality sequence: lint, tests, speed, docs, translations, security |
Each consumer repo (`devx`, `grm`, `infra`) gets its own copy — there are
no cross-repo composite action references. This avoids a single-point-of-failure
where a bad devx master commit would break all repos' CI simultaneously.
See ADR-0003 for the full design rationale and Gitea 1.27 constraints.
## Import rules
1. **`src/devx/` is self-contained** — the package never imports from outside `src/`
+32 -20
View File
@@ -38,22 +38,33 @@ The single validation job. Consolidates the former `quality`,
`detect-changes`, `release-dry-run`, `pr-review`, and `pre-merge-check`
jobs into one job to save checkout+setup overhead. Runs on every PR.
**Quality steps**
**Setup and quality steps** (composite actions)
The main quality gate:
The validate job uses three composite actions from `.gitea/actions/`:
1. **Lint all** — ruff check, ruff format check, pyright, bandit, actionlint
(via `make lint-all`)
2. **Unit tests with 100% coverage**`make pytest-cov`
3. **Check unit test speed** — `python -m devx.tools.check_test_speed
--max-seconds 4 --max-single-seconds 0.5`
4. **Documentation coverage check**`python -m devx.ci.doc_coverage
--fail-on-missing`
5. **Translation completeness check**`python -m devx.ci.check_translations`
6. **Dependency security scan**`pip-audit --desc --skip-editable`
(best-effort, non-blocking)
7. **Workflow dry-run validation**`make workflow-dryrun` via act_runner
(best-effort, skipped if act_runner is not installed)
1. **`setup-env`** — runs `make setup-image` to link the pre-built venv
and install the project (no-deps mode)
2. **`quality-checks`** — runs the 6-step quality gate:
- **Lint all** — ruff check, ruff format check, pyright, bandit,
actionlint (via `make lint-all`)
- **Unit tests with 100% coverage**`make pytest-cov`
- **Check unit test speed** — `python -m devx.tools.check_test_speed
--max-seconds 15 --max-single-seconds 0.5`
- **Documentation gate**`make devx-docs-check` (coverage + stale
refs + lint + version refs + prose)
- **Translation completeness check**`python -m devx.ci.check_translations`
- **Dependency security scan**`pip-audit --desc --skip-editable`
(best-effort, non-blocking)
3. **`notify-failure`** — creates a Gitea issue if any step fails
The quality-checks action accepts inputs (`package`, `test-speed-max`,
`test-speed-max-single`, `translations-file`) for cross-repo reuse.
See ADR-0003 for the composite action design rationale.
**Workflow dry-run validation** (inline step, not part of composite action)
`make workflow-dryrun` via act_runner (best-effort, skipped if
act_runner is not installed).
**`detect-changes` step**
@@ -445,7 +456,7 @@ instance levels. Falls back to `MOLECULE_RUNNERS` repo variable or
`DEFAULT_MAX_RUNNERS` (3).
```bash
python -m devx.ci.discover_runners --owner <owner> --repo <repo> [--count] [--indices]
python -m devx.molecule.discover_runners --owner <owner> --repo <repo> [--count] [--indices]
```
### `detect_release_commit.py`
@@ -574,8 +585,9 @@ picks up the new version number). This prevents infinite loops.
## Failure handling
Every job in the CI and post-merge workflows has a `notify_failure` step
that 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.
Every job in the CI, post-merge, and build-images workflows uses the
`notify-failure` composite action (`.gitea/actions/notify-failure`),
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.
+3 -3
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.50.3",
"devx>=0.50.7",
]
[project.optional-dependencies]
dev = [
"devx>=0.50.3",
"devx>=0.50.7",
]
```
@@ -142,7 +142,7 @@ infrastructure = [
- `devx.ci.notify_failure` — Create Gitea issues on CI failures
- `devx.ci.distribute_files` — Parallel test file distribution
- `devx.ci.distribute_items` — Parallel item distribution across runners
- `devx.ci.discover_runners` — Dynamic runner discovery via Gitea API
- `devx.molecule.discover_runners` — Dynamic runner discovery via Gitea API
### Development Tools (`devx.tools.*`)
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.50.3"
__version__ = "0.50.7"
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""Discover available Gitea Actions runners for dynamic job distribution.
.. deprecated:: Phase 1c
Use :mod:`devx.molecule.discover_runners` instead. This module is a
thin wrapper that re-exports the canonical implementation from
:mod:`devx.molecule.discover_runners` for backward compatibility
with existing workflow references and Makefile targets.
The canonical implementation lives in
:mod:`devx.molecule.discover_runners` because runner discovery is
primarily used by the molecule test distribution pipeline. CI
workflows that still reference ``python -m devx.ci.discover_runners``
will continue to work via this wrapper, but new code should import
from :mod:`devx.molecule.discover_runners` directly.
Usage:
python3 -m devx.ci.discover_runners --owner oblachno-oss --repo devx
python3 -m devx.ci.discover_runners --indices
python3 -m devx.ci.discover_runners --count
"""
from __future__ import annotations
import sys
import warnings
from devx.molecule.discover_runners import ( # noqa: F401 — re-exported for backward compat
DEFAULT_MAX_RUNNERS,
generate_indices,
get_runner_count,
main,
query_runners,
)
_DEPRECATION_MSG = (
"devx.ci.discover_runners is deprecated; use devx.molecule.discover_runners instead. "
"This wrapper will be removed in a future release."
)
def _emit_deprecation_warning() -> None:
"""Emit a DeprecationWarning when this module is imported for CLI use."""
warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
if __name__ == "__main__": # pragma: no cover
_emit_deprecation_warning()
sys.exit(main())
-7
View File
@@ -81,13 +81,6 @@ def ci_detect_release_commit(args: tuple[str, ...]) -> None:
_run_module("devx.ci.detect_release_commit", list(args))
@ci.command("discover-runners")
@click.argument("args", nargs=-1)
def ci_discover_runners(args: tuple[str, ...]) -> None:
"""Discover available Gitea Actions runners."""
_run_module("devx.ci.discover_runners", list(args))
@ci.command("doc-coverage")
@click.argument("args", nargs=-1)
def ci_doc_coverage(args: tuple[str, ...]) -> None:
+79 -23
View File
@@ -22,18 +22,38 @@ Usage::
from __future__ import annotations
import logging
import os
import platform
import shutil
import tarfile
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
import click
from tenacity import (
Retrying,
before_sleep_log,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
TARGET_DIR = Path.home() / ".local" / "bin"
logger = logging.getLogger(__name__)
# Retry configuration for transient network failures during download.
# 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. 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"
GIT_CLIFF_VERSION = "2.13.1"
@@ -64,8 +84,30 @@ def _ensure_target_dir() -> Path:
return TARGET_DIR
def _download(url: str, dest: Path) -> None:
"""Download a file from ``url`` to ``dest`` with a 60s timeout."""
def _download(url: str, dest: Path, *, _sleep=None) -> None:
"""Download a file from ``url`` to ``dest`` with retry and 60s timeout.
Retries up to ``MAX_DOWNLOAD_RETRIES`` times on transient network
errors (``URLError``, ``OSError`` from connection resets) using
exponential backoff. This handles momentary GitHub releases
connection drops that were causing CI image builds to fail.
The ``_sleep`` kwarg is for tests to avoid real sleeping; production
code should leave it as ``None`` (uses ``time.sleep``).
"""
retrying = Retrying(
stop=stop_after_attempt(MAX_DOWNLOAD_RETRIES),
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,
reraise=True,
)
retrying(_do_download, url, dest)
def _do_download(url: str, dest: Path) -> None:
"""Single download attempt — called by :func:`_download` retry wrapper."""
with urllib.request.urlopen(url, timeout=60) as resp, open(dest, "wb") as f: # nosec B310
shutil.copyfileobj(resp, f)
@@ -89,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:
@@ -136,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
@@ -234,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
-7
View File
@@ -73,13 +73,6 @@ class TestCiCommands:
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.detect_release_commit", [])
@patch("devx.cli._run_module")
def test_ci_discover_runners(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "discover-runners", "positional"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.discover_runners", ["positional"])
@patch("devx.cli._run_module")
def test_ci_doc_coverage(self, mock_run: MagicMock) -> None:
runner = CliRunner()
+3 -31
View File
@@ -1,8 +1,6 @@
"""Unit tests for devx.ci.discover_runners (deprecated wrapper).
"""Unit tests for devx.molecule.discover_runners.
The wrapper re-exports from devx.molecule.discover_runners; these tests
verify backward compatibility by importing through the wrapper and
patching the canonical implementation's requests module.
Tests verify the canonical implementation by patching the requests module.
"""
import json
@@ -13,7 +11,7 @@ import click
import pytest
from click.testing import CliRunner
from devx.ci.discover_runners import (
from devx.molecule.discover_runners import (
DEFAULT_MAX_RUNNERS,
generate_indices,
get_runner_count,
@@ -266,29 +264,3 @@ class TestMain:
assert result.output.strip() == "3"
args, _ = mock_count.call_args
assert args[1] is None # token passed as None when missing
class TestDeprecationWrapper:
def test_re_exports_canonical_symbols(self) -> None:
"""The wrapper re-exports the canonical implementation's symbols."""
from devx.ci import discover_runners as ci_mod
from devx.molecule import discover_runners as mol_mod
assert ci_mod.query_runners is mol_mod.query_runners
assert ci_mod.get_runner_count is mol_mod.get_runner_count
assert ci_mod.generate_indices is mol_mod.generate_indices
assert ci_mod.main is mol_mod.main
assert ci_mod.DEFAULT_MAX_RUNNERS is mol_mod.DEFAULT_MAX_RUNNERS
def test_emit_deprecation_warning(self) -> None:
"""_emit_deprecation_warning issues a DeprecationWarning."""
import warnings
from devx.ci.discover_runners import _emit_deprecation_warning
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
_emit_deprecation_warning()
assert len(caught) == 1
assert issubclass(caught[0].category, DeprecationWarning)
assert "deprecated" in str(caught[0].message)
+106
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import platform
import urllib.error
from pathlib import Path
from unittest.mock import patch
@@ -68,6 +69,67 @@ class TestDownload:
mock_urlopen.assert_called_once()
assert dest.read_bytes() == b"data"
def test_download_retries_on_transient_error(self, tmp_path: Path) -> None:
"""Download retries on URLError then succeeds."""
dest = tmp_path / "file.bin"
call_count = [0]
class _FakeResponse:
def __init__(self) -> None:
self._sent = False
def __enter__(self) -> _FakeResponse:
return self
def __exit__(self, *args: object) -> None:
pass
def read(self, n: int = -1) -> bytes:
if self._sent:
return b""
self._sent = True
return b"data"
def _flaky_urlopen(url: str, timeout: int = 60):
call_count[0] += 1
if call_count[0] < 2:
raise urllib.error.URLError("Remote end closed connection")
return _FakeResponse()
with patch("urllib.request.urlopen", side_effect=_flaky_urlopen):
install_tools._download("https://example.com/file", dest, _sleep=lambda _: None)
assert call_count[0] == 2
assert dest.read_bytes() == b"data"
def test_download_fails_after_max_retries(self, tmp_path: Path) -> None:
"""Download raises after MAX_DOWNLOAD_RETRIES attempts."""
dest = tmp_path / "file.bin"
call_count = [0]
def _always_fail(url: str, timeout: int = 60):
call_count[0] += 1
raise urllib.error.URLError("Remote end closed connection")
with patch("urllib.request.urlopen", side_effect=_always_fail):
with pytest.raises(urllib.error.URLError):
install_tools._download("https://example.com/file", dest, _sleep=lambda _: None)
assert call_count[0] == install_tools.MAX_DOWNLOAD_RETRIES
assert not dest.exists()
def test_download_no_retry_on_non_transient_error(self, tmp_path: Path) -> None:
"""Download does not retry on non-network errors (e.g. ValueError)."""
dest = tmp_path / "file.bin"
call_count = [0]
def _fail_with_value_error(url: str, timeout: int = 60):
call_count[0] += 1
raise ValueError("not a network error")
with patch("urllib.request.urlopen", side_effect=_fail_with_value_error):
with pytest.raises(ValueError):
install_tools._download("https://example.com/file", dest, _sleep=lambda _: None)
assert call_count[0] == 1
class TestDownloadBinary:
def test_download(self, tmp_path: Path) -> None:
@@ -139,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: