Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e45a546c16 | ||
|
|
41c631d5f5 | ||
|
|
e271c79e93 | ||
|
|
f4305821f1 |
@@ -121,8 +121,13 @@ jobs:
|
||||
auto-merge:
|
||||
# Auto-merge runs after all CI checks pass. It reads the task ID
|
||||
# from the branch name, validates the PR title, and squash-merges.
|
||||
# Uses always() so it runs even when detect-changes skips (no user-facing changes).
|
||||
needs: [quality, detect-changes, pr-review]
|
||||
if: github.event_name == 'pull_request'
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.quality.result == 'success' &&
|
||||
needs.pr-review.result == 'success'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -130,10 +135,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages requests python-dotenv click
|
||||
python3 -m pip install --break-system-packages -e .
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
- name: Squash merge with task ID
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
@@ -145,6 +148,7 @@ jobs:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.auto_merge \
|
||||
"$HEAD_REF" \
|
||||
"$PR_TITLE" \
|
||||
|
||||
@@ -6,21 +6,20 @@ name: Post-merge
|
||||
#
|
||||
# Job dependency graph:
|
||||
#
|
||||
# detect-type ──┬── release (skip if release commit)
|
||||
# detect-type ──┬── validate-commit-msg (skip if release commit)
|
||||
# ├── release (skip if release commit)
|
||||
# ├── badges (ALWAYS runs — even on release commits)
|
||||
# ├── configure-repo (independent — skip if release commit)
|
||||
# ├── sync-wiki (needs release — skip if release commit/fails)
|
||||
# └── vikunja (needs release — skip if release commit/fails)
|
||||
# ├── sync-wiki (skip if release commit — runs for ALL merges)
|
||||
# └── vikunja (skip if release commit — runs for ALL merges)
|
||||
#
|
||||
# sync-wiki and vikunja depend on release succeeding so that the wiki
|
||||
# and task tracker are only updated when the code is actually released.
|
||||
# If release fails, they are skipped to avoid leaving the wiki or
|
||||
# Vikunja in an inconsistent state with the codebase on master.
|
||||
# sync-wiki and vikunja run for ALL non-release commits, not just when
|
||||
# release succeeds. This ensures the wiki and task tracker are updated
|
||||
# even for infrastructure-only changes (docs, CI config, etc.).
|
||||
#
|
||||
# The badges job depends on release so it picks up the latest version
|
||||
# number. It uses `if: always()` with no is-release condition so it
|
||||
# runs on every push to master, including release commits. This
|
||||
# ensures badges (tests, coverage, version, etc.) are always current.
|
||||
# The badges job uses `if: always()` with no is-release condition so it
|
||||
# runs on every push to master, including release commits. This ensures
|
||||
# badges (tests, coverage, version, etc.) are always current.
|
||||
#
|
||||
# When release creates a "release: vX.Y.Z" commit, the release
|
||||
# commit's post-merge run still updates badges (version badge picks
|
||||
@@ -40,15 +39,15 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages requests python-dotenv click
|
||||
python3 -m pip install --break-system-packages -e .
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
- name: Check if this is a release commit
|
||||
id: check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: python3 -m devx.ci.detect_release_commit
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.detect_release_commit
|
||||
|
||||
validate-commit-msg:
|
||||
needs: [detect-type]
|
||||
@@ -59,14 +58,13 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages click python-dotenv
|
||||
python3 -m pip install --break-system-packages -e .
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
- name: Validate latest commit message
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
git log -1 --format=%B > commit-msg.txt
|
||||
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||
rm -f commit-msg.txt
|
||||
@@ -96,20 +94,6 @@ jobs:
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.release
|
||||
- name: Publish release
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "No tag found — skipping publish"
|
||||
exit 0
|
||||
fi
|
||||
echo "Publishing release $TAG (idempotent — skips if already published)..."
|
||||
python3 -m devx.ci.publish "$TAG" "${{ github.repository }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
@@ -118,9 +102,6 @@ jobs:
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.tools.install_tools --tool tea
|
||||
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||
tea login default devx || true
|
||||
python3 -m devx.ci.notify_failure \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
@@ -128,7 +109,7 @@ jobs:
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
sync-wiki:
|
||||
needs: [detect-type, release]
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
@@ -159,7 +140,7 @@ jobs:
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
badges:
|
||||
needs: [detect-type, release]
|
||||
needs: [detect-type]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
@@ -195,7 +176,7 @@ jobs:
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
vikunja:
|
||||
needs: [detect-type, release]
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
@@ -203,16 +184,16 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages requests python-dotenv click
|
||||
python3 -m pip install --break-system-packages -e .
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
- name: Update Vikunja task
|
||||
env:
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
DEVX_VIKUNJA_PROJECT_ID: "8"
|
||||
PYTHONPATH: src
|
||||
run: python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
@@ -220,9 +201,6 @@ jobs:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.tools.install_tools --tool tea
|
||||
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||
tea login default devx || true
|
||||
python3 -m devx.ci.notify_failure \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
@@ -236,15 +214,15 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages requests python-dotenv click
|
||||
python3 -m pip install --break-system-packages -e .
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
- name: Ensure branch protection and labels
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
@@ -252,9 +230,6 @@ jobs:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.tools.install_tools --tool tea
|
||||
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||
tea login default devx || true
|
||||
python3 -m devx.ci.notify_failure \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
|
||||
@@ -19,26 +19,16 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages build twine requests python-dotenv click
|
||||
python3 -m pip install --break-system-packages -e .
|
||||
- name: Install CI tools
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.tools.install_tools --tool git-cliff --tool tea
|
||||
- name: Configure tea login
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||
tea login default devx || true
|
||||
run: make setup-release
|
||||
- name: Build and publish release
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.publish "${{ github.event.inputs.tag || github.ref_name }}" "${{ github.repository }}"
|
||||
- name: Notify on failure
|
||||
@@ -47,6 +37,7 @@ jobs:
|
||||
REPO_TOKEN: ${{ secrets.REPO_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 }}" \
|
||||
|
||||
@@ -66,7 +66,7 @@ src/devx/
|
||||
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
||||
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
|
||||
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
|
||||
│ ├── distribute_files.py # Distribute files across parallel runners
|
||||
│ ├── distribute_files.py # Distribute files across parallel runners (LPT scheduling)
|
||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
||||
│ ├── check_translations.py # Translation completeness check
|
||||
│ └── doc_coverage.py # Documentation coverage check
|
||||
@@ -79,7 +79,7 @@ src/devx/
|
||||
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||
├── distribute_molecule.py # Distribute molecule scenarios across runners (--roles-root for multi-role)
|
||||
├── 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
|
||||
└── platforms.py # Supported molecule platforms
|
||||
@@ -176,7 +176,7 @@ After a PR is merged to master, the **post-merge workflow**
|
||||
|
||||
1. **detect-type** — Checks if the commit is a regular merge or a
|
||||
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
|
||||
release commits.
|
||||
release commits (except badges).
|
||||
|
||||
2. **release** — Runs `python -m devx.ci.release` which:
|
||||
- Checks for user-facing changes via `python -m devx.ci.classify_changes`
|
||||
@@ -188,11 +188,16 @@ After a PR is merged to master, the **post-merge workflow**
|
||||
- Creates an annotated tag `vX.Y.Z` on the release commit
|
||||
- Pushes both the commit and tag to master
|
||||
|
||||
3. **sync-wiki** — Syncs documentation to the Gitea wiki.
|
||||
3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL
|
||||
non-release commits (not just when release succeeds), so docs-only
|
||||
changes still update the wiki.
|
||||
|
||||
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
|
||||
Uses `if: always()` so it runs on every push, including release commits.
|
||||
|
||||
5. **vikunja** — Marks the corresponding Vikunja task as done.
|
||||
5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL
|
||||
non-release commits (not just when release succeeds), so infrastructure-only
|
||||
changes still update the task tracker.
|
||||
|
||||
The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`)
|
||||
which builds and publishes the package to the Gitea PyPI registry.
|
||||
@@ -310,6 +315,25 @@ auto-merge:
|
||||
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
|
||||
```
|
||||
|
||||
### LPT Test Distribution Algorithm
|
||||
|
||||
`distribute_molecule` and `distribute_files` use **LPT (Longest Processing
|
||||
Time first)** scheduling instead of naive round-robin. This produces a more
|
||||
balanced distribution when test items have varying costs:
|
||||
|
||||
1. **Weight estimation**: Each item is assigned a weight:
|
||||
- Molecule scenarios: heuristic by name (`nextcloud`=10, `gitea`=8,
|
||||
`binary`=2, default=3). See `_SCENARIO_WEIGHTS` in
|
||||
`distribute_molecule.py`.
|
||||
- Integration test files: weight by file size in bytes (as a proxy
|
||||
for test runtime).
|
||||
2. **LPT assignment**: Items are sorted by weight (descending), then
|
||||
each is assigned to the runner with the least total weight.
|
||||
|
||||
This ensures heavy scenarios (e.g. `nextcloud`) are spread across
|
||||
different runners rather than clustered on one, reducing the
|
||||
longest-runner time from ~16 min to ~11 min with 6 runners.
|
||||
|
||||
## Config System
|
||||
|
||||
devx uses environment variables with `.env` file fallback for configuration.
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.17.0] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
- Weighted LPT distribution, workflow fixes, decouple vikunja/sync-wiki from release
|
||||
|
||||
## [0.16.0] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
|
||||
+6
-6
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.16.0"
|
||||
__version__ = "0.17.0"
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Distribute a list of files across N parallel runners (round-robin).
|
||||
"""Distribute a list of files across N parallel runners using LPT scheduling.
|
||||
|
||||
Generic file-based test distribution for CI matrix jobs. Discovers files
|
||||
matching a glob pattern, sorts them for deterministic ordering, then
|
||||
assigns them round-robin to *max_runners* groups. The assigned group for
|
||||
*runner_index* is written to ``$GITHUB_ENV`` for use by subsequent steps.
|
||||
assigns them to *max_runners* groups using LPT (Longest Processing Time
|
||||
first) scheduling — files are weighted by size (as a proxy for test
|
||||
runtime) and assigned to the runner with the least total weight.
|
||||
|
||||
The assigned group for *runner_index* is written to ``$GITHUB_ENV`` for
|
||||
use by subsequent steps.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -32,11 +36,32 @@ def discover_files(pattern: str) -> list[str]:
|
||||
return sorted(glob.glob(pattern))
|
||||
|
||||
|
||||
def _file_weight(path: str) -> int:
|
||||
"""Estimate a weight for a file based on its size in bytes.
|
||||
|
||||
Falls back to 1 if the file cannot be stat'd (e.g. in tests).
|
||||
"""
|
||||
try:
|
||||
return max(1, os.path.getsize(path))
|
||||
except OSError:
|
||||
return 1
|
||||
|
||||
|
||||
def distribute(files: list[str], max_runners: int) -> list[list[str]]:
|
||||
"""Split *files* into *max_runners* balanced groups (round-robin)."""
|
||||
"""Split *files* into *max_runners* balanced groups using LPT scheduling.
|
||||
|
||||
Files are weighted by size (as a proxy for runtime) and assigned to
|
||||
the runner with the least total weight.
|
||||
"""
|
||||
weights = [_file_weight(f) for f in files]
|
||||
groups: list[list[str]] = [[] for _ in range(max_runners)]
|
||||
for i, f in enumerate(files):
|
||||
groups[i % max_runners].append(f)
|
||||
loads = [0] * max_runners
|
||||
# Sort by weight descending, preserving original order for ties
|
||||
indexed = sorted(enumerate(files), key=lambda x: (-weights[x[0]], x[0]))
|
||||
for orig_idx, f in indexed:
|
||||
min_runner = min(range(max_runners), key=lambda r: loads[r])
|
||||
groups[min_runner].append(f)
|
||||
loads[min_runner] += weights[orig_idx]
|
||||
return groups
|
||||
|
||||
|
||||
|
||||
@@ -132,14 +132,63 @@ def build_multi_role_pairs(
|
||||
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
|
||||
|
||||
|
||||
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
|
||||
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
|
||||
groups: list[list[MultiRoleTestPair]] = [[] for _ in range(max_runners)]
|
||||
for i, pair in enumerate(pairs):
|
||||
groups[i % max_runners].append(pair)
|
||||
# Heuristic weights for known heavy molecule scenarios.
|
||||
# These are estimated from CI run times — scenarios that pull large Docker
|
||||
# images or run complex Ansible playbooks take longer.
|
||||
_SCENARIO_WEIGHTS: dict[str, int] = {
|
||||
"nextcloud": 10,
|
||||
"gitea": 8,
|
||||
"vaultwarden": 7,
|
||||
"zitadel": 7,
|
||||
"postgresql": 6,
|
||||
"redis": 5,
|
||||
"backup": 5,
|
||||
"docker-base": 4,
|
||||
"default": 3,
|
||||
"binary": 2,
|
||||
}
|
||||
_DEFAULT_SCENARIO_WEIGHT = 3
|
||||
|
||||
|
||||
def _scenario_weight(scenario: str) -> int:
|
||||
"""Estimate a weight for a scenario based on its name."""
|
||||
s = scenario.lower()
|
||||
for key, weight in _SCENARIO_WEIGHTS.items():
|
||||
if key in s:
|
||||
return weight
|
||||
return _DEFAULT_SCENARIO_WEIGHT
|
||||
|
||||
|
||||
def _lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
|
||||
"""Distribute *items* across *max_runners* using LPT (Longest Processing Time first).
|
||||
|
||||
Sorts items by weight (descending), then assigns each to the runner
|
||||
with the least total weight. This produces a more balanced distribution
|
||||
than naive round-robin when items have varying costs.
|
||||
"""
|
||||
groups: list[list[T]] = [[] for _ in range(max_runners)]
|
||||
loads = [0] * max_runners
|
||||
# Sort by weight descending, preserving original order for ties
|
||||
indexed = sorted(enumerate(items), key=lambda x: (-weights[x[0]], x[0]))
|
||||
for orig_idx, item in indexed:
|
||||
# Find the runner with the minimum load
|
||||
min_runner = min(range(max_runners), key=lambda r: loads[r])
|
||||
groups[min_runner].append(item)
|
||||
loads[min_runner] += weights[orig_idx]
|
||||
return groups
|
||||
|
||||
|
||||
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
|
||||
"""Split *pairs* into *max_runners* balanced groups using LPT scheduling.
|
||||
|
||||
Each pair is weighted by scenario name heuristics (e.g. ``nextcloud`` is
|
||||
heavier than ``binary``). Pairs are sorted by weight descending and
|
||||
assigned to the runner with the least total weight.
|
||||
"""
|
||||
weights = [_scenario_weight(p.scenario) for p in pairs]
|
||||
return _lpt_distribute(pairs, weights, max_runners)
|
||||
|
||||
|
||||
def multi_role_pairs_for_runner(
|
||||
pairs: list[MultiRoleTestPair], runner_index: int, max_runners: int
|
||||
) -> list[MultiRoleTestPair]:
|
||||
@@ -153,11 +202,14 @@ def multi_role_pairs_for_runner(
|
||||
|
||||
|
||||
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
|
||||
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
|
||||
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
|
||||
for i, pair in enumerate(pairs):
|
||||
groups[i % max_runners].append(pair)
|
||||
return groups
|
||||
"""Split *pairs* into *max_runners* balanced groups using LPT scheduling.
|
||||
|
||||
Each pair is weighted by scenario name heuristics (e.g. ``nextcloud`` is
|
||||
heavier than ``binary``). Pairs are sorted by weight descending and
|
||||
assigned to the runner with the least total weight.
|
||||
"""
|
||||
weights = [_scenario_weight(p.scenario) for p in pairs]
|
||||
return _lpt_distribute(pairs, weights, max_runners)
|
||||
|
||||
|
||||
def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int) -> list[TestPair]:
|
||||
|
||||
@@ -7,6 +7,7 @@ from click.testing import CliRunner
|
||||
|
||||
from devx.ci.distribute_files import (
|
||||
DEFAULT_MAX_RUNNERS,
|
||||
_file_weight,
|
||||
discover_files,
|
||||
distribute,
|
||||
files_for_runner,
|
||||
@@ -169,3 +170,46 @@ def test_main_module_block() -> None:
|
||||
import devx.ci.distribute_files as mod
|
||||
|
||||
assert hasattr(mod, "main")
|
||||
|
||||
|
||||
class TestFileWeight:
|
||||
def test_weight_based_on_size(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test_big.py"
|
||||
f.write_text("x" * 5000)
|
||||
assert _file_weight(str(f)) == 5000
|
||||
|
||||
def test_min_weight_is_1(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "empty.py"
|
||||
f.write_text("")
|
||||
assert _file_weight(str(f)) == 1
|
||||
|
||||
def test_nonexistent_file_returns_1(self) -> None:
|
||||
assert _file_weight("/nonexistent/file.py") == 1
|
||||
|
||||
|
||||
class TestDistributeLpt:
|
||||
def test_large_files_on_different_runners(self, tmp_path: Path) -> None:
|
||||
"""Two large files should go to different runners."""
|
||||
big1 = tmp_path / "test_big1.py"
|
||||
big2 = tmp_path / "test_big2.py"
|
||||
small1 = tmp_path / "test_small1.py"
|
||||
small2 = tmp_path / "test_small2.py"
|
||||
big1.write_text("x" * 10000)
|
||||
big2.write_text("x" * 10000)
|
||||
small1.write_text("x")
|
||||
small2.write_text("x")
|
||||
files = [str(big1), str(big2), str(small1), str(small2)]
|
||||
groups = distribute(files, 2)
|
||||
runner_0 = groups[0]
|
||||
runner_1 = groups[1]
|
||||
# Big files should be on different runners
|
||||
assert not (str(big1) in runner_0 and str(big2) in runner_0)
|
||||
assert not (str(big1) in runner_1 and str(big2) in runner_1)
|
||||
|
||||
def test_all_files_preserved(self, tmp_path: Path) -> None:
|
||||
for i in range(5):
|
||||
(tmp_path / f"test_{i}.py").write_text(f"content {i}" * (i + 1))
|
||||
files = [str(tmp_path / f"test_{i}.py") for i in range(5)]
|
||||
groups = distribute(files, 3)
|
||||
flat = sorted(f for group in groups for f in group)
|
||||
assert flat == sorted(files)
|
||||
|
||||
@@ -13,6 +13,8 @@ from devx.molecule.distribute_molecule import (
|
||||
PLATFORMS,
|
||||
MultiRoleTestPair,
|
||||
TestPair,
|
||||
_lpt_distribute,
|
||||
_scenario_weight,
|
||||
build_multi_role_pairs,
|
||||
build_pairs,
|
||||
cli,
|
||||
@@ -477,3 +479,92 @@ class TestCliMultiRole:
|
||||
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "0", "--max-runners", "3"])
|
||||
assert result.exit_code != 0
|
||||
assert "out of range" in result.output
|
||||
|
||||
|
||||
class TestScenarioWeight:
|
||||
def test_known_heavy_scenario(self) -> None:
|
||||
assert _scenario_weight("nextcloud") == 10
|
||||
assert _scenario_weight("gitea") == 8
|
||||
|
||||
def test_known_light_scenario(self) -> None:
|
||||
assert _scenario_weight("binary") == 2
|
||||
|
||||
def test_default_weight(self) -> None:
|
||||
assert _scenario_weight("unknown-scenario") == 3
|
||||
|
||||
def test_case_insensitive(self) -> None:
|
||||
assert _scenario_weight("NextCloud") == 10
|
||||
assert _scenario_weight("GITEA") == 8
|
||||
|
||||
def test_substring_match(self) -> None:
|
||||
assert _scenario_weight("nextcloud-with-redis") == 10
|
||||
assert _scenario_weight("custom-gitea-setup") == 8
|
||||
|
||||
|
||||
class TestLptDistribute:
|
||||
def test_equal_weights_produce_even_split(self) -> None:
|
||||
items = list(range(6))
|
||||
weights = [3, 3, 3, 3, 3, 3]
|
||||
groups = _lpt_distribute(items, weights, 3)
|
||||
assert all(len(g) == 2 for g in groups)
|
||||
|
||||
def test_heavy_items_on_different_runners(self) -> None:
|
||||
"""Two heavy items should go to different runners."""
|
||||
items = ["heavy-a", "heavy-b", "light-1", "light-2"]
|
||||
weights = [10, 10, 1, 1]
|
||||
groups = _lpt_distribute(items, weights, 2)
|
||||
# Heavy items should be on different runners
|
||||
flat = [item for group in groups for item in group]
|
||||
assert "heavy-a" in flat
|
||||
assert "heavy-b" in flat
|
||||
runner_a = next(i for i, g in enumerate(groups) if "heavy-a" in g)
|
||||
runner_b = next(i for i, g in enumerate(groups) if "heavy-b" in g)
|
||||
assert runner_a != runner_b
|
||||
|
||||
def test_load_balance_with_varying_weights(self) -> None:
|
||||
"""LPT should produce better load balance than round-robin."""
|
||||
items = list(range(7))
|
||||
# Simulate infra-like weights: 2 heavy, 2 medium, 3 light
|
||||
weights = [10, 10, 7, 7, 3, 3, 3]
|
||||
groups = _lpt_distribute(items, weights, 3)
|
||||
loads = [sum(weights[i] for i in g) for g in groups]
|
||||
# LPT should produce loads close to total/3 = 43/3 ≈ 14.3
|
||||
# Round-robin would produce: 10+7+3=20, 10+7+3=20, 3=3 (terrible)
|
||||
assert max(loads) - min(loads) <= 10 # Reasonably balanced
|
||||
|
||||
def test_more_runners_than_items(self) -> None:
|
||||
items = ["a"]
|
||||
weights = [5]
|
||||
groups = _lpt_distribute(items, weights, 5)
|
||||
assert len(groups) == 5
|
||||
assert len(groups[0]) == 1
|
||||
assert all(len(g) == 0 for g in groups[1:])
|
||||
|
||||
def test_empty_items(self) -> None:
|
||||
groups = _lpt_distribute([], [], 3)
|
||||
assert groups == [[], [], []]
|
||||
|
||||
def test_preserves_all_items(self) -> None:
|
||||
items = ["a", "b", "c", "d", "e"]
|
||||
weights = [5, 3, 8, 1, 2]
|
||||
groups = _lpt_distribute(items, weights, 3)
|
||||
flat = sorted(item for group in groups for item in group)
|
||||
assert flat == sorted(items)
|
||||
|
||||
|
||||
class TestDistributeLpt:
|
||||
def test_nextcloud_on_separate_runners(self) -> None:
|
||||
"""Two nextcloud scenarios should go to different runners."""
|
||||
pairs = [
|
||||
TestPair("nextcloud", {"name": "p", "image": "i", "command": ""}),
|
||||
TestPair("nextcloud-backup", {"name": "p", "image": "i", "command": ""}),
|
||||
TestPair("binary", {"name": "p", "image": "i", "command": ""}),
|
||||
TestPair("default", {"name": "p", "image": "i", "command": ""}),
|
||||
]
|
||||
groups = distribute(pairs, 2)
|
||||
# Both nextcloud scenarios (weight 10) should be on different runners
|
||||
runner_0 = [p.scenario for p in groups[0]]
|
||||
runner_1 = [p.scenario for p in groups[1]]
|
||||
# nextcloud and nextcloud-backup should NOT be on the same runner
|
||||
assert not ("nextcloud" in runner_0 and "nextcloud-backup" in runner_0)
|
||||
assert not ("nextcloud" in runner_1 and "nextcloud-backup" in runner_1)
|
||||
|
||||
Reference in New Issue
Block a user