Compare commits

...
38 Commits
Author SHA1 Message Date
devx-ci-bot 6c4157b5c6 release: v0.20.3
Post-merge / detect-type (push) Successful in 10s
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Build Images / detect-type (push) Successful in 40s
Build Images / build-and-push (push) Has been skipped
Post-merge / badges (push) Successful in 29s
Build Images / cleanup (push) Has been skipped
2026-06-27 07:42:30 +00:00
emil ea7ddb2036 DEVX-73: fix: release publish failures and duplicate release commits
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / vikunja (push) Successful in 12s
Build Images / detect-type (push) Successful in 38s
Post-merge / release (push) Successful in 25s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / badges (push) Successful in 31s
Post-merge / publish (push) Failing after 15s
Build Images / build-and-push (push) Successful in 2m47s
Build Images / cleanup (push) Successful in 51s
2026-06-27 07:41:50 +00:00
gitea-actions-bot ef63ada2f0 chore: update badge URLs to commit bc706211 [skip ci] 2026-06-27 07:21:27 +00:00
gitea-actions-bot cdd5f5a8da chore: update badge URLs to commit a0473322 [skip ci] 2026-06-27 07:20:49 +00:00
devx-ci-bot 63ae375b4b release: v0.20.2
Post-merge / detect-type (push) Successful in 15s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 34s
2026-06-27 07:20:10 +00:00
emil 8862ea4639 DEVX-72: ci: add hadolint Dockerfile linter to CI
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 8s
Build Images / detect-type (push) Successful in 40s
Post-merge / release (push) Failing after 26s
Build Images / build-and-push (push) Successful in 3m6s
Build Images / cleanup (push) Successful in 49s
Post-merge / sync-wiki (push) Successful in 23s
Post-merge / vikunja (push) Successful in 14s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 35s
2026-06-27 07:19:31 +00:00
gitea-actions-bot 8ca0a1b208 chore: update badge URLs to commit 55d2bb5c [skip ci] 2026-06-27 03:08:47 +00:00
emil 670f5a099a DEVX-71: ci: use pre-built tier images in CI workflows
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 26s
Post-merge / release (push) Successful in 19s
2026-06-27 03:08:06 +00:00
gitea-actions-bot a5277a0790 chore: update badge URLs to commit 177dda82 [skip ci] 2026-06-27 02:42:47 +00:00
devx-ci-bot 203d16b19d release: v0.20.2
Post-merge / detect-type (push) Successful in 37s
Post-merge / validate-commit-msg (push) Has been skipped
Build Images / detect-type (push) Successful in 40s
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Build Images / cleanup (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Build Images / build-and-push (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 57s
2026-06-27 04:41:07 +02:00
gitea-actions-bot 12871fb343 chore: update badge URLs to commit 917159e3 [skip ci] 2026-06-27 04:40:59 +02:00
emil a585ef09b6 DEVX-70: fix: correct sed substitution in ci-full Dockerfile
Build Images / detect-type (push) Successful in 35s
Post-merge / detect-type (push) Successful in 33s
Post-merge / validate-commit-msg (push) Successful in 37s
Post-merge / sync-wiki (push) Successful in 53s
Post-merge / vikunja (push) Successful in 45s
Post-merge / badges (push) Successful in 1m24s
Post-merge / configure-repo (push) Successful in 41s
Post-merge / release (push) Successful in 1m37s
Post-merge / publish (push) Failing after 51s
Build Images / build-and-push (push) Successful in 2m59s
Build Images / cleanup (push) Successful in 1m13s
2026-06-27 02:38:53 +00:00
gitea-actions-bot 92aea7df10 chore: update badge URLs to commit d8f48949 [skip ci] 2026-06-27 04:29:12 +02:00
gitea-actions-bot ee3ad74634 chore: update badge URLs to commit ac4f73ef [skip ci] 2026-06-27 04:27:29 +02:00
devx-ci-bot 626ea67b28 release: v0.20.1
Post-merge / badges (push) Successful in 56s
Build Images / detect-type (push) Successful in 45s
Build Images / cleanup (push) Has been skipped
Build Images / build-and-push (push) Has been skipped
Post-merge / detect-type (push) Successful in 42s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / publish (push) Has been skipped
2026-06-27 02:26:36 +00:00
emil 87c3fa6634 DEVX-69: fix: correct image references in tier Dockerfiles
Build Images / detect-type (push) Successful in 50s
Post-merge / detect-type (push) Successful in 48s
Post-merge / sync-wiki (push) Successful in 56s
Post-merge / validate-commit-msg (push) Successful in 1m2s
Post-merge / release (push) Successful in 1m21s
Post-merge / vikunja (push) Successful in 49s
Post-merge / configure-repo (push) Successful in 48s
Post-merge / badges (push) Successful in 1m10s
Post-merge / publish (push) Failing after 51s
Build Images / build-and-push (push) Failing after 3m7s
Build Images / cleanup (push) Has been skipped
2026-06-27 02:24:24 +00:00
gitea-actions-bot 048d161192 chore: update badge URLs to commit d600d21c [skip ci] 2026-06-27 01:37:07 +00:00
gitea-actions-bot 762eee4a55 chore: update badge URLs to commit d0ea4c99 [skip ci] 2026-06-27 03:36:02 +02:00
devx-ci-bot d84958fab7 release: v0.20.0
Build Images / detect-type (push) Successful in 41s
Build Images / build-and-push (push) Has been skipped
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / detect-type (push) Successful in 40s
Build Images / cleanup (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 50s
2026-06-27 01:35:31 +00:00
emil c0238e75df DEVX-68: feat: add pre-built Docker runner images and tested image build/push tools
Build Images / detect-type (push) Successful in 33s
Build Images / build-and-push (push) Failing after 2m54s
Build Images / cleanup (push) Has been skipped
Post-merge / detect-type (push) Successful in 32s
Post-merge / configure-repo (push) Successful in 40s
Post-merge / sync-wiki (push) Successful in 42s
Post-merge / release (push) Successful in 53s
Post-merge / vikunja (push) Successful in 1m1s
Post-merge / validate-commit-msg (push) Successful in 1m8s
Post-merge / badges (push) Successful in 1m19s
Post-merge / publish (push) Failing after 38s
2026-06-27 01:34:05 +00:00
gitea-actions-bot d4ddbd7e7a chore: update badge URLs to commit fd23e5b2 [skip ci] 2026-06-27 00:01:53 +00:00
gitea-actions-bot 08b993fc4a chore: update badge URLs to commit ebe8b016 [skip ci] 2026-06-27 01:58:56 +02:00
devx-ci-bot bee730a52f release: v0.19.3
Post-merge / detect-type (push) Successful in 45s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m8s
2026-06-26 23:58:24 +00:00
emil d0a4a774a0 DEVX-67: refactor: make molecule weights configurable via pyproject.toml
Post-merge / detect-type (push) Successful in 1m22s
Post-merge / validate-commit-msg (push) Successful in 1m4s
Post-merge / release (push) Successful in 1m15s
Post-merge / sync-wiki (push) Successful in 1m16s
Post-merge / vikunja (push) Successful in 55s
Post-merge / badges (push) Successful in 1m33s
Post-merge / configure-repo (push) Successful in 51s
Post-merge / publish (push) Successful in 1m4s
2026-06-26 23:55:33 +00:00
gitea-actions-bot 882f9805ed chore: update badge URLs to commit a45dd92c [skip ci] 2026-06-27 01:40:17 +02:00
gitea-actions-bot a85e0baaea chore: update badge URLs to commit e0f5da9a [skip ci] 2026-06-27 01:39:01 +02:00
devx-ci-bot 663572768b release: v0.19.2
Post-merge / detect-type (push) Successful in 41s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 52s
2026-06-27 01:38:41 +02:00
emil 1f2533872d DEVX-66: fix: calibrate molecule weights from actual CI execution times
Post-merge / detect-type (push) Successful in 31s
Post-merge / sync-wiki (push) Successful in 48s
Post-merge / vikunja (push) Successful in 49s
Post-merge / validate-commit-msg (push) Successful in 45s
Post-merge / configure-repo (push) Successful in 52s
Post-merge / release (push) Successful in 1m9s
Post-merge / badges (push) Successful in 1m24s
Post-merge / publish (push) Successful in 52s
2026-06-26 23:36:58 +00:00
gitea-actions-bot cb7e9dbc7e chore: update badge URLs to commit 0ab8d43a [skip ci] 2026-06-26 21:39:38 +02:00
devx-ci-bot f5081e10b1 release: v0.19.1
Post-merge / detect-type (push) Successful in 42s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 55s
2026-06-26 21:36:46 +02:00
gitea-actions-bot cabc0d1adc chore: update badge URLs to commit 816fbbbb [skip ci] 2026-06-26 19:36:16 +00:00
emil 7c1ecd6ff9 DEVX-65: refactor: consolidate publish.yml into post-merge.yml
Post-merge / detect-type (push) Successful in 30s
Post-merge / vikunja (push) Successful in 46s
Post-merge / sync-wiki (push) Successful in 48s
Post-merge / validate-commit-msg (push) Successful in 51s
Post-merge / badges (push) Successful in 59s
Post-merge / release (push) Successful in 1m30s
Post-merge / configure-repo (push) Successful in 1m34s
Post-merge / publish (push) Successful in 54s
2026-06-26 19:34:45 +00:00
gitea-actions-bot 5063f659bc chore: update badge URLs to commit f977a79e [skip ci] 2026-06-26 21:04:44 +02:00
gitea-actions-bot 96c77a0ba4 chore: update badge URLs to commit c9a5f623 [skip ci] 2026-06-26 19:03:18 +00:00
devx-ci-bot 40dd578d89 release: v0.19.0
Post-merge / detect-type (push) Successful in 37s
Post-merge / release (push) Has been skipped
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Publish Release / publish (push) Successful in 48s
Post-merge / badges (push) Successful in 48s
2026-06-26 19:03:14 +00:00
emil 7ea9b4a96b DEVX-64: feat: add skip_ref_prefixes config to check_agent_docs
Post-merge / detect-type (push) Successful in 38s
Post-merge / validate-commit-msg (push) Successful in 45s
Post-merge / sync-wiki (push) Successful in 51s
Post-merge / release (push) Successful in 57s
Post-merge / badges (push) Successful in 59s
Post-merge / configure-repo (push) Successful in 1m1s
Post-merge / vikunja (push) Successful in 1m22s
2026-06-26 19:01:39 +00:00
gitea-actions-bot 08ceaf484f chore: update badge URLs to commit c003531a [skip ci] 2026-06-26 20:52:05 +02:00
gitea-actions-bot fb6b0fda1d chore: update badge URLs to commit c5811a72 [skip ci] 2026-06-26 18:50:24 +00:00
28 changed files with 2518 additions and 511 deletions
+20
View File
@@ -0,0 +1,20 @@
.venv/
.git/
.gitea/
tests/
docs/
*.egg-info/
__pycache__/
htmlcov/
.coverage
dist/
build/
*.md
!README.md
.env
.env.example
activate.sh
activate.fish
activate.zsh
hooks/
.devin/
+131
View File
@@ -0,0 +1,131 @@
name: Build Images
# Builds and pushes pre-built Docker runner images to the Gitea registry.
# These images eliminate the 40-120s setup tax on every CI job by baking
# devx and all dependencies into the image.
#
# Triggers:
# - On push to master (after post-merge release completes)
# - Manually via workflow_dispatch
#
# The workflow builds 3 tier images in sequence:
# ci-base → ci-quality → ci-full
#
# Each tier builds FROM the previous one, so they must be built in order.
# After pushing, a cleanup job removes old versions (keeps last 2 + latest).
on:
push:
branches: [master]
paths:
- docker/**
- pyproject.toml
- src/devx/**
workflow_dispatch:
jobs:
detect-type:
runs-on: docker
timeout-minutes: 5
outputs:
is-release: ${{ steps.check.outputs.is-release }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up environment
run: make setup-ci
- name: Check if this is a release commit
id: check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.ci.detect_release_commit
build-and-push:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-release
- name: Docker registry login
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
REGISTRY_USERNAME: ${{ vars.REGISTRY_USERNAME }}
run: |
. .venv/bin/activate
echo "$REPO_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$REGISTRY_USERNAME" --password-stdin
- name: Build and push tier images
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
REGISTRY_USERNAME: ${{ vars.REGISTRY_USERNAME }}
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
# Build ci-base first (it's the base for ci-quality and ci-full)
python3 -m devx.tools.build_image \
--dockerfile docker/ci-base/Dockerfile \
--name oblachno-oss/runner-images/ci-base \
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push --pull
# Build ci-quality (FROM ci-base-latest)
python3 -m devx.tools.build_image \
--dockerfile docker/ci-quality/Dockerfile \
--name oblachno-oss/runner-images/ci-quality \
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push
# Build ci-full (FROM ci-quality-latest)
python3 -m devx.tools.build_image \
--dockerfile docker/ci-full/Dockerfile \
--name oblachno-oss/runner-images/ci-full \
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push
- name: Notify on failure
if: failure()
env:
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 }}" \
--run-id "${{ github.run_id }}" \
--workflow "build-images/build-and-push" \
--commit "${{ github.sha }}"
cleanup:
needs: [build-and-push]
if: always() && needs.build-and-push.result == 'success'
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up environment
run: make setup-ci
- name: Clean up old image versions
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.tools.clean_images \
--owner oblachno-oss \
--name oblachno-oss/runner-images/ci-base \
--name oblachno-oss/runner-images/ci-quality \
--name oblachno-oss/runner-images/ci-full \
--keep 2
+25 -5
View File
@@ -8,11 +8,15 @@ on:
jobs:
quality:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup-quality
run: make setup-image
- name: Lint all
run: |
. .venv/bin/activate
@@ -60,7 +64,11 @@ jobs:
detect-changes:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
outputs:
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps:
@@ -68,7 +76,7 @@ jobs:
with:
fetch-depth: 0
- name: Set up environment
run: make setup-ci
run: make setup-image
- name: Detect changed paths
id: detect
env:
@@ -84,13 +92,17 @@ jobs:
needs: [quality, detect-changes]
if: needs.detect-changes.outputs.user-facing-changed == 'true'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup-release
run: make setup-image
- name: Release dry-run validation
env:
PYTHONPATH: src
@@ -102,11 +114,15 @@ jobs:
pr-review:
if: github.event_name == 'pull_request'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup-ci
run: make setup-image
- name: Run automated PR review
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
@@ -129,14 +145,18 @@ jobs:
needs.quality.result == 'success' &&
needs.pr-review.result == 'success'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }}
- name: Set up environment
run: make setup-ci
run: make setup-image
- name: Squash merge with task ID
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
+93 -15
View File
@@ -1,13 +1,13 @@
name: Post-merge
# Runs on every push to master. A single workflow with conditional jobs
# replaces separate workflows for release, wiki sync, badges, and
# Vikunja task updates.
# for release, publish, wiki sync, badges, and Vikunja task updates.
#
# Job dependency graph:
#
# detect-type ──┬── validate-commit-msg (skip if release commit)
# ├── release (skip if release commit)
# │ └── publish (needs release — builds & publishes to PyPI)
# ├── badges (ALWAYS runs — even on release commits)
# ├── configure-repo (independent — skip if release commit)
# ├── sync-wiki (skip if release commit — runs for ALL merges)
@@ -21,9 +21,10 @@ name: Post-merge
# 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
# up the new version). Other jobs skip. The tag push triggers publish.yml.
# When release creates a "release: vX.Y.Z" commit and tag, the publish
# job (which depends on release) builds and publishes the package to the
# Gitea PyPI registry. The release commit's post-merge run still updates
# badges (version badge picks up the new version). Other jobs skip.
on:
push:
@@ -32,7 +33,11 @@ on:
jobs:
detect-type:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
outputs:
is-release: ${{ steps.check.outputs.is-release }}
steps:
@@ -40,7 +45,7 @@ jobs:
with:
fetch-depth: 1
- name: Set up environment
run: make setup-ci
run: make setup-image
- name: Check if this is a release commit
id: check
env:
@@ -53,13 +58,17 @@ jobs:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 5
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up environment
run: make setup-ci
run: make setup-image
- name: Validate latest commit message
env:
PYTHONPATH: src
@@ -73,7 +82,13 @@ jobs:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15
defaults:
run:
shell: bash
outputs:
tag: ${{ steps.release-tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
@@ -82,18 +97,26 @@ jobs:
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-release
run: make setup-image
- name: Configure git
run: |
git config user.name "devx-ci-bot"
git config user.email "devx-ci-bot@oblachno.fyi"
- name: Run release
id: release-tag
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release
- name: Extract tag (fallback if GITHUB_OUTPUT not set)
if: steps.release-tag.outputs.tag == ''
run: |
tag=$(git describe --tags --abbrev=0 2>/dev/null || true)
if [ -n "$tag" ]; then
echo "tag=$tag" >> "$GITHUB_OUTPUT"
fi
- name: Notify on failure
if: failure()
env:
@@ -108,17 +131,60 @@ jobs:
--workflow "post-merge/release" \
--commit "${{ github.sha }}"
sync-wiki:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
publish:
needs: [release]
if: needs.release.outputs.tag != ''
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup-ci
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-image
- 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 "${{ needs.release.outputs.tag }}" "${{ github.repository }}"
- name: Notify on failure
if: failure()
env:
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 }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/publish" \
--commit "${{ github.sha }}"
sync-wiki:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup-image
- name: Sync documentation to wiki
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
@@ -143,7 +209,11 @@ jobs:
needs: [detect-type]
if: always()
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
@@ -155,7 +225,7 @@ jobs:
git fetch origin master
git reset --hard origin/master
- name: Set up environment
run: make setup-ci
run: make setup-image
- name: Generate and push badges
env:
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
@@ -179,13 +249,17 @@ jobs:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup-ci
run: make setup-image
- name: Update Vikunja task
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
@@ -211,11 +285,15 @@ jobs:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup-ci
run: make setup-image
- name: Ensure branch protection and labels
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
-46
View File
@@ -1,46 +0,0 @@
name: Publish Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to publish (e.g. v0.9.11)'
required: true
type: string
jobs:
publish:
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
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
if: failure()
env:
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 }}" \
--run-id "${{ github.run_id }}" \
--workflow "publish" \
--commit "${{ github.sha }}"
+14
View File
@@ -0,0 +1,14 @@
# Hadolint configuration for devx Dockerfiles
# https://github.com/hadolint/hadolint#configure
ignored:
- DL3008 # Don't require pinning apt package versions
- DL3013 # Don't require pinning pip package versions
- DL3018 # Don't require pinning apk package versions
- DL3007 # Using latest is intentional for tier images (rebuilt on every merge)
- SC2102 # False positive: pip extras [release,molecule,deploy] look like shell ranges
trustedRegistries:
- git.oblachno.oblachno.fyi
- docker.io
- gitea/runner-images
+49 -2
View File
@@ -74,6 +74,9 @@ src/devx/
├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── setup.py # Environment setup (venv, deps, hooks)
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea
│ ├── install_checkmake.py # Install checkmake (Makefile linter)
│ ├── build_image.py # Build and push Docker images to Gitea registry
│ ├── clean_images.py # Clean up old Docker image versions from Gitea registry
│ ├── check_test_speed.py # Measure unit test execution time
│ ├── check_mutable_globals.py # Detect module-level mutable globals (test isolation bugs)
│ ├── check_pyproject_deps.py # Validate pyproject.toml deps have documentation comments
@@ -204,8 +207,9 @@ After a PR is merged to master, the **post-merge workflow**
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.
6. **publish** — Runs after release succeeds (needs: release). Builds and
publishes the package to the Gitea PyPI registry. Gets the tag from the
release job's `tag` output (written via `GITHUB_OUTPUT`).
### Smart CI: User-Facing vs Workflow-Only Changes
@@ -423,6 +427,11 @@ projects.
| `devx-check-test-speed` | Verify test suite timing |
| `devx-pre-push` | Run lint + tests before push |
| `devx-clean` | Remove caches, build artifacts, coverage data |
| `devx-setup-image` | Link /opt/venv + install project (for pre-built image CI jobs) |
| `devx-build-images` | Build Docker images from manifest (no push) |
| `devx-push-images` | Build and push Docker images to Gitea registry |
| `devx-build-images-dry-run` | Show what would be built/pushed |
| `devx-clean-images` | Delete old image versions (keep last 2 + latest) |
**Variables** (set BEFORE including devx.mak):
- `DEVX_PYTHON` — Python executable (default: `python3`)
@@ -432,6 +441,44 @@ projects.
- `DEVX_COV_PKG` — coverage package (default: `src/devx`)
- `DEVX_TEST_PATHS` — pytest paths (default: `tests/`)
- `DEVX_PR_BASE` — PR base branch (default: `master`)
- `DEVX_GITEA_REGISTRY` — registry URL (default: `git.oblachno.oblachno.fyi`)
- `DEVX_IMAGE_MANIFEST` — path to JSON manifest (default: `docker/images.json`)
- `DEVX_IMAGE_OWNER` — package owner for cleanup (default: `oblachno-oss`)
## Pre-built Docker Runner Images
devx builds and publishes three tier images to the Gitea container registry
to eliminate the 40-120s setup tax on every CI job:
| Image | Contains | Used by jobs |
|-------|----------|-------------|
| `ci-base-latest` | Python 3.12 + devx[ci] + tea | detect-changes, detect-type, validate-commit-msg, pr-review, auto-merge, sync-wiki, vikunja, configure-repo |
| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake | quality, badges |
| `ci-full-latest` | ci-quality + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, release-dry-run, molecule-tests, deploy jobs |
**Build process** (in `build-images.yml` workflow):
1. `ci-base` builds FROM `gitea/runner-images:ubuntu-latest`
2. `ci-quality` builds FROM `ci-base-latest`
3. `ci-full` builds FROM `ci-quality-latest`
Each image is tagged `latest` and pushed to
`git.oblachno.oblachno.fyi/oblachno-oss/runner-images:<tier>-latest`.
**Using images in workflows**:
```yaml
jobs:
quality:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup-image # links /opt/venv, installs project (no-deps)
```
**Image build/push tools** (tested Python modules):
- `devx.tools.build_image` — Build and push Docker images from Dockerfile or manifest
- `devx.tools.clean_images` — Delete old image versions via Gitea API (keep last N + latest)
**Usage in project Makefile**:
```makefile
+50
View File
@@ -2,6 +2,56 @@
All notable changes to this project will be documented in this file.
## [0.20.3] - 2026-06-27
### Bug Fixes
- Release publish failures and duplicate release commits
## [0.20.2] - 2026-06-27
## [0.20.2] - 2026-06-27
### Bug Fixes
- Correct sed substitution in ci-full Dockerfile
## [0.20.1] - 2026-06-27
### Bug Fixes
- Correct image references in tier Dockerfiles
## [0.20.0] - 2026-06-27
### Features
- Add pre-built Docker runner images and tested image build/push tools
## [0.19.3] - 2026-06-26
### Refactor
- Make molecule weights configurable via pyproject.toml
## [0.19.2] - 2026-06-26
### Bug Fixes
- Calibrate molecule weights from actual CI execution times
## [0.19.1] - 2026-06-26
### Refactor
- Consolidate publish.yml into post-merge.yml
## [0.19.0] - 2026-06-26
### Features
- Add skip_ref_prefixes config to check_agent_docs
## [0.18.0] - 2026-06-26
### Features
+32 -4
View File
@@ -1,4 +1,4 @@
.PHONY: all setup setup-ci setup-quality setup-release install update lint lint-all test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed
.PHONY: all setup setup-ci setup-quality setup-release setup-image install update lint lint-all lint-dockerfiles test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed build-images push-images build-images-dry-run clean-images
PYTHON := python3
VENV := .venv
@@ -25,10 +25,15 @@ setup-quality: $(VENV)/bin/activate .env install-tools
# Setup for release jobs (needs git-cliff, tea, lint tools)
setup-release: $(VENV)/bin/activate .env
@$(BIN)/pip install -e '.[ci,lint]' 2>/dev/null; \
@$(BIN)/pip install -e '.[ci,lint,release]' 2>/dev/null; \
$(BIN)/python -m devx.tools.install_tools --tool git-cliff --tool tea; \
export PATH="$(HOME)/.local/bin:$$PATH"; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-pre-commit
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint,release" --no-pre-commit
# Setup for pre-built image jobs (deps already in image, just link venv + install project)
setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install -e . --no-deps 2>/dev/null; \
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
.env:
@if [ ! -f .env ]; then cp .env.example .env; echo "Created .env from .env.example — please edit it."; fi
@@ -91,9 +96,18 @@ create-pr: devx-create-pr
push-with-pr: devx-push-with-pr
git-push: devx-push
lint-all: lint workflow-lint
lint-all: lint workflow-lint lint-dockerfiles
@echo "[lint-all] All linting checks passed."
lint-dockerfiles:
@echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..."
@if command -v hadolint >/dev/null 2>&1; then \
find docker -name 'Dockerfile*' -exec hadolint {} +; \
echo "[lint-dockerfiles] All Dockerfiles passed."; \
else \
echo "[lint-dockerfiles] hadolint not found — skipping (install with: pip install hadolint or download from GitHub)"; \
fi
test-unit: devx-test-unit
pytest-cov: devx-pytest-cov
@@ -105,3 +119,17 @@ pre-push: lint-all pytest-cov
clean: devx-clean
@echo "[clean] Done."
# ── Docker image management ──────────────────────────────────────────────────
build-images: devx-build-images
@echo "[build-images] Done."
push-images: devx-push-images
@echo "[push-images] Done."
build-images-dry-run: devx-build-images-dry-run
@echo "[build-images-dry-run] Done."
clean-images: devx-clean-images
@echo "[clean-images] Done."
+6 -6
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/0a0adc8dce9a146068a35ade3e9f301c3a5e8cca/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0a0adc8dce9a146068a35ade3e9f301c3a5e8cca/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0a0adc8dce9a146068a35ade3e9f301c3a5e8cca/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0a0adc8dce9a146068a35ade3e9f301c3a5e8cca/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0a0adc8dce9a146068a35ade3e9f301c3a5e8cca/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0a0adc8dce9a146068a35ade3e9f301c3a5e8cca/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/bc706211123c7f520e8aaf36b7c71fac4ce8d149/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/bc706211123c7f520e8aaf36b7c71fac4ce8d149/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/bc706211123c7f520e8aaf36b7c71fac4ce8d149/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/bc706211123c7f520e8aaf36b7c71fac4ce8d149/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/bc706211123c7f520e8aaf36b7c71fac4ce8d149/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/bc706211123c7f520e8aaf36b7c71fac4ce8d149/python.svg)](https://www.python.org/downloads/)
## Why devx?
+26
View File
@@ -0,0 +1,26 @@
# ci-base — lightweight image for CI jobs that only need devx core + tea.
#
# Used by: detect-type, detect-changes, validate-commit-msg, pr-review,
# auto-merge, sync-wiki, vikunja, configure-repo, discover-runners,
# molecule-report, discover-integration-runners
#
# Jobs using this image: setup is instant (ln -s /opt/venv .venv)
# No pip install needed — devx and all deps are pre-installed.
FROM gitea/runner-images:ubuntu-latest
# Create a virtual environment with all deps pre-installed
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:/root/.local/bin:$PATH"
# Install devx from local source (build context = devx repo root)
COPY . /tmp/devx
RUN pip install --no-cache-dir --upgrade pip setuptools wheel \
&& pip install --no-cache-dir /tmp/devx[ci] \
&& rm -rf /tmp/devx
# Install tea CLI (for Gitea API operations in CI)
RUN python3 -m devx.tools.install_tools --tool tea
# Workspace directory (actions/checkout mounts repo here)
WORKDIR /workspace
+26
View File
@@ -0,0 +1,26 @@
# ci-full — heaviest image, includes everything for release, molecule, deploy.
#
# Used by: release, publish, release-dry-run, molecule-tests,
# provision-infra, deploy-observability, provision-zitadel,
# deploy-customer, integration-tests
#
# Layers on top of ci-quality: adds release tools, molecule, deploy deps,
# git-cliff, and OpenTofu.
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Install devx[release,molecule,deploy] from local source
COPY . /tmp/devx
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
&& rm -rf /tmp/devx
# Install git-cliff (changelog generator for release job)
RUN python3 -m devx.tools.install_tools --tool git-cliff
# Install OpenTofu (for infra deploy jobs)
RUN ARCH=$(uname -m | sed 's/x86_64/amd64/') \
&& VERSION=1.12.3 \
&& curl -fsSL "https://github.com/opentofu/opentofu/releases/download/v${VERSION}/tofu_${VERSION}_$(uname -s | tr '[:upper:]' '[:lower:]')_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin tofu
+22
View File
@@ -0,0 +1,22 @@
# ci-quality — image for lint, type-checking, badge generation.
#
# Used by: quality (lint-all + pytest-cov + checks), badges (generate_badges
# runs ruff/pyright/bandit to produce quality badge)
#
# Layers on top of ci-base: adds lint tools + actionlint + checkmake.
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
# Install devx[lint] from local source (adds ruff, pyright, bandit, etc.)
COPY . /tmp/devx
RUN pip install --no-cache-dir /tmp/devx[lint] \
&& rm -rf /tmp/devx
# Install CI/CD binary tools
RUN python3 -m devx.tools.install_tools --tool actionlint \
&& python3 -m devx.tools.install_checkmake
# Install hadolint (Dockerfile linter)
RUN curl -fsSL "https://github.com/hadolint/hadolint/releases/download/v2.12.0/hadolint-Linux-x86_64" \
-o /usr/local/bin/hadolint \
&& chmod +x /usr/local/bin/hadolint
+20
View File
@@ -0,0 +1,20 @@
[
{
"name": "oblachno-oss/runner-images/ci-base",
"dockerfile": "docker/ci-base/Dockerfile",
"context": ".",
"tags": ["latest"]
},
{
"name": "oblachno-oss/runner-images/ci-quality",
"dockerfile": "docker/ci-quality/Dockerfile",
"context": ".",
"tags": ["latest"]
},
{
"name": "oblachno-oss/runner-images/ci-full",
"dockerfile": "docker/ci-full/Dockerfile",
"context": ".",
"tags": ["latest"]
}
]
+6 -6
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/0a0adc8dce9a146068a35ade3e9f301c3a5e8cca/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0a0adc8dce9a146068a35ade3e9f301c3a5e8cca/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0a0adc8dce9a146068a35ade3e9f301c3a5e8cca/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0a0adc8dce9a146068a35ade3e9f301c3a5e8cca/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0a0adc8dce9a146068a35ade3e9f301c3a5e8cca/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0a0adc8dce9a146068a35ade3e9f301c3a5e8cca/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/bc706211123c7f520e8aaf36b7c71fac4ce8d149/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/bc706211123c7f520e8aaf36b7c71fac4ce8d149/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/bc706211123c7f520e8aaf36b7c71fac4ce8d149/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/bc706211123c7f520e8aaf36b7c71fac4ce8d149/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/bc706211123c7f520e8aaf36b7c71fac4ce8d149/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/bc706211123c7f520e8aaf36b7c71fac4ce8d149/python.svg)](https://www.python.org/downloads/)
## Overview
+20 -7
View File
@@ -26,14 +26,13 @@ devx = "devx.cli:cli"
version = {attr = "devx.__version__"}
[project.optional-dependencies]
# Minimal deps for CI scripts that only need click/dotenv/requests
# Test runners (pytest + coverage + parallel execution)
ci = [
"pytest>=9.1.0",
"pytest-cov>=7.1.0",
"build>=1.5.0",
"twine>=6.2.0",
"pytest-xdist>=3.8",
]
# Lint and type-checking tools (quality job)
# Lint and type-checking tools (quality job, badge generation)
lint = [
"ruff>=0.15.17",
"pyright>=1.1.410",
@@ -41,16 +40,30 @@ lint = [
"pip-audit>=2.10",
"pre-commit>=4.6.0",
]
# Molecule testing (optional — for projects with Ansible roles)
# Release tools (build + publish to PyPI/Gitea registry)
release = [
"build>=1.5.0",
"twine>=6.2.0",
]
# Molecule testing (for projects with Ansible roles)
molecule = [
"molecule>=26.4.0",
"molecule-docker>=2.1.0",
"ansible-lint>=26.4.0",
"ansible>=14.0.0",
"ansible-core>=2.15,<2.17",
]
# Deploy tools (for infra staging/production deployments)
deploy = [
"ansible-core>=2.15,<2.17",
"boto3>=1.34",
"docker>=7.0",
"jinja2>=3.1",
"pyyaml>=6.0",
"cryptography>=41.0",
]
# Full dev environment (local development)
dev = [
"devx[ci,lint]",
"devx[ci,lint,release,molecule]",
"build>=1.3.0",
"twine>=6.2.0",
]
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.18.0"
__version__ = "0.20.3"
+32
View File
@@ -317,6 +317,21 @@ def run_tests() -> None:
click.echo(_("Tests passed."))
def _write_github_output(tag: str) -> None:
"""Write the release tag to GITHUB_OUTPUT for downstream jobs.
This allows a publish job (needs: release) to read the tag via
``${{ needs.release.outputs.tag }}`` instead of relying on
tag-push event triggering a separate workflow.
"""
github_output = os.environ.get("GITHUB_OUTPUT")
if not github_output:
return
with open(github_output, "a") as f: # noqa: PTH123
f.write(f"tag={tag}\n")
click.echo(_("Wrote tag {tag} to GITHUB_OUTPUT.", tag=tag))
def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool:
"""Create an annotated tag with the changelog as message and push it.
@@ -345,6 +360,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
if not dry_run:
# Ensure the existing tag is pushed
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
_write_github_output(tag)
return False
tag_msg = f"Release v{new_version}\n\n{changelog}"
if dry_run:
@@ -352,6 +368,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
return True
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
_write_github_output(tag)
return True
@@ -617,6 +634,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
tag=release_tag,
)
)
_write_github_output(release_tag)
return
# Tag is missing — recover by creating and pushing it
click.echo(
@@ -651,6 +669,20 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
return
current_tag = get_latest_tag()
# If the bumped version equals the current tag version, there's nothing
# new to release. git-cliff didn't bump because the commits since the last
# tag don't warrant a version change (e.g., only ci:/chore: commits).
# Creating a release commit with the same version would cause a tag
# conflict.
if current_tag and current_tag.lstrip("v") == new_version:
click.echo(
_(
"Version stays at v{version} — no version bump from git-cliff. "
"Commits since last tag don't warrant a new release. Skipping.",
version=new_version,
)
)
return
click.echo(
_(
"Bumping version: {current} -> v{new_version}",
+55
View File
@@ -249,3 +249,58 @@ devx-clean:
@find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
@find . -type f -name "*.pyc" -delete 2>/dev/null || true
@rm -rf .coverage htmlcov/ dist/ build/ *.egg-info/ .molecule/ 2>/dev/null || true
# ── Pre-built image setup ─────────────────────────────────────────────────────
#
# When running inside a pre-built Docker runner image (ci-base, ci-quality,
# ci-full), all deps are already installed in /opt/venv. This target links
# the venv and installs the project itself (no-deps, fast).
# Falls back to devx-setup-ci if /opt/venv is not present (local dev).
devx-setup-image:
@if [ -d /opt/venv ]; then \
ln -sf /opt/venv $(DEVX_VENV); \
. $(DEVX_BIN)/activate && pip install -e . --no-deps 2>/dev/null; \
echo "[devx-setup-image] Linked /opt/venv and installed project (no-deps)."; \
else \
echo "[devx-setup-image] /opt/venv not found — falling back to devx-setup-ci"; \
$(MAKE) devx-setup-ci; \
fi
# ── Docker image build / push / cleanup ───────────────────────────────────────
#
# Variables:
# DEVX_GITEA_REGISTRY — registry URL (default: git.oblachno.oblachno.fyi)
# DEVX_IMAGE_MANIFEST — path to JSON manifest (default: docker/images.json)
# DEVX_IMAGE_OWNER — package owner for cleanup (default: oblachno-oss)
DEVX_GITEA_REGISTRY ?= git.oblachno.oblachno.fyi
DEVX_IMAGE_MANIFEST ?= docker/images.json
DEVX_IMAGE_OWNER ?= oblachno-oss
# Build all images from manifest (no push)
devx-build-images:
@$(DEVX_PYTHON) -m devx.tools.build_image --manifest $(DEVX_IMAGE_MANIFEST) --pull
# Build and push all images to the Gitea registry
devx-push-images:
@$(DEVX_PYTHON) -m devx.tools.build_image \
--manifest $(DEVX_IMAGE_MANIFEST) \
--registry $(DEVX_GITEA_REGISTRY) \
--push --pull
# Dry-run: show what would be built/pushed
devx-build-images-dry-run:
@$(DEVX_PYTHON) -m devx.tools.build_image \
--manifest $(DEVX_IMAGE_MANIFEST) \
--registry $(DEVX_GITEA_REGISTRY) \
--push --dry-run
# Clean up old image versions (keep last 2 + latest)
devx-clean-images:
@$(DEVX_PYTHON) -m devx.tools.clean_images \
--owner $(DEVX_IMAGE_OWNER) \
--name oblachno-oss/runner-images/ci-base \
--name oblachno-oss/runner-images/ci-quality \
--name oblachno-oss/runner-images/ci-full \
--keep 2
+76 -20
View File
@@ -19,6 +19,7 @@ Usage:
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from pathlib import Path
@@ -132,27 +133,82 @@ def build_multi_role_pairs(
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
# 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,
}
# --- Molecule weight configuration ---
#
# Weights are loaded from ``[tool.devx.molecule.weights]`` in
# ``pyproject.toml``. Each project (infra, grm, …) contributes its own
# weights calibrated from actual CI execution times.
#
# Two key formats are supported:
# - ``"scenario" = weight`` — applies to any role with that scenario name
# - ``"role/scenario" = weight`` — role-specific (takes priority)
#
# Example pyproject.toml::
#
# [tool.devx.molecule.weights]
# "nextcloud" = 15
# "app_container/customer-apps" = 11
# "restore/default" = 11
# "default" = 3
#
# If no configuration is found, a generic default weight is used for all
# scenarios (producing a round-robin distribution).
_DEFAULT_SCENARIO_WEIGHT = 3
def _scenario_weight(scenario: str) -> int:
"""Estimate a weight for a scenario based on its name."""
def _load_molecule_weights(pyproject_path: str = "pyproject.toml") -> tuple[dict[str, int], dict[tuple[str, str], int]]:
"""Load molecule weights from ``[tool.devx.molecule.weights]`` in pyproject.toml.
Returns a tuple of ``(scenario_weights, role_scenario_weights)``:
- ``scenario_weights``: maps scenario name weight (applies to any role)
- ``role_scenario_weights``: maps (role, scenario) weight (role-specific)
"""
path = Path(pyproject_path)
if not path.exists():
return {}, {}
try:
with open(path, "rb") as f: # noqa: PTH123
data = tomllib.load(f)
except (tomllib.TOMLDecodeError, OSError):
return {}, {}
weights_raw = data.get("tool", {}).get("devx", {}).get("molecule", {}).get("weights", {})
if not isinstance(weights_raw, dict):
return {}, {}
scenario_weights: dict[str, int] = {}
role_scenario_weights: dict[tuple[str, str], int] = {}
for key, value in weights_raw.items():
if not isinstance(value, int):
continue
if "/" in key:
role, scenario = key.split("/", 1)
role_scenario_weights[(role.lower(), scenario.lower())] = value
else:
scenario_weights[key.lower()] = value
return scenario_weights, role_scenario_weights
# Load weights once at import time (like devx.config and classify_changes)
_SCENARIO_WEIGHTS, _ROLE_SCENARIO_WEIGHTS = _load_molecule_weights()
def _scenario_weight(scenario: str, role: str | None = None) -> int:
"""Estimate a weight for a scenario based on its name and optionally its role.
Role-specific weights (``"role/scenario"``) take priority over
scenario-name-only weights (``"scenario"``). Falls back to the
default weight if no configuration matches.
"""
s = scenario.lower()
if role is not None:
r = role.lower()
key = (r, s)
if key in _ROLE_SCENARIO_WEIGHTS:
return _ROLE_SCENARIO_WEIGHTS[key]
for key, weight in _SCENARIO_WEIGHTS.items():
if key in s:
return weight
@@ -181,11 +237,11 @@ def _lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) ->
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
Each pair is weighted by role+scenario heuristics (e.g. ``nextcloud`` is
heavier than ``simple-app``). 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]
weights = [_scenario_weight(p.scenario, p.role) for p in pairs]
return _lpt_distribute(pairs, weights, max_runners)
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
"""Build and push Docker images to a Gitea container registry.
Replaces raw ``docker build`` / ``docker push`` shell commands with a
tested Python tool. Supports:
- Building from any Dockerfile with a configurable context directory
- Tagging with multiple tags (e.g. ``latest`` + version)
- Optional push to a Gitea registry (with login)
- Dry-run mode (prints commands without executing)
Usage::
# Build a single image
python3 -m devx.tools.build_image \\
--dockerfile docker/ci-base/Dockerfile \\
--tag ci-base:latest \\
--tag ci-base:0.19.3
# Build and push to registry
python3 -m devx.tools.build_image \\
--dockerfile docker/ci-base/Dockerfile \\
--tag ci-base:latest \\
--tag ci-base:0.19.3 \\
--registry git.oblachno.oblachno.fyi \\
--push
# Build multiple images (from a manifest file)
python3 -m devx.tools.build_image --manifest docker/images.json --push
The manifest file is a JSON list of dicts, each with:
- ``name``: image name (e.g. ``ci-base``)
- ``dockerfile``: path to Dockerfile (relative to repo root)
- ``context``: build context directory (optional, defaults to repo root)
- ``tags``: list of tags (optional, defaults to ``["latest"]``)
Registry authentication uses ``REPO_TOKEN`` (or ``GITEA_REGISTRY_TOKEN``)
and ``REGISTRY_USERNAME`` (or ``GITEA_REGISTRY_USERNAME``) environment
variables, matching the existing CI workflow patterns.
"""
from __future__ import annotations
import json
import os
import subprocess # nosec B404
from dataclasses import dataclass, field
from pathlib import Path
import click
from devx.i18n import _
@dataclass
class ImageSpec:
"""Specification for a single Docker image to build."""
name: str
dockerfile: str
context: str = "."
tags: list[str] = field(default_factory=lambda: ["latest"])
@classmethod
def from_dict(cls, data: dict[str, object]) -> ImageSpec:
"""Create an ImageSpec from a dict (e.g. from a JSON manifest)."""
name = str(data.get("name", ""))
if not name:
raise ValueError(_("Image manifest entry missing 'name'"))
dockerfile = str(data.get("dockerfile", ""))
if not dockerfile:
raise ValueError(_("Image manifest entry missing 'dockerfile'"))
context = str(data.get("context", "."))
tags_raw = data.get("tags", ["latest"])
if not isinstance(tags_raw, list):
raise ValueError(_("Image 'tags' must be a list"))
tags = [str(t) for t in tags_raw] if tags_raw else ["latest"]
return cls(name=name, dockerfile=dockerfile, context=context, tags=tags)
def load_manifest(path: str | Path) -> list[ImageSpec]:
"""Load a JSON manifest file describing images to build.
The file must contain a JSON list of dicts with at least ``name`` and
``dockerfile`` keys. ``context`` and ``tags`` are optional.
Returns a list of :class:`ImageSpec` instances.
"""
p = Path(path)
if not p.is_file():
raise click.ClickException(_("Manifest file not found: {path}", path=p))
with p.open() as f: # noqa: PTH123
data = json.load(f)
if not isinstance(data, list):
raise click.ClickException(_("Manifest must be a JSON list"))
return [ImageSpec.from_dict(entry) for entry in data]
def build_full_tag(registry: str | None, name: str, tag: str) -> str:
"""Build a full image tag, optionally prefixed with a registry.
>>> build_full_tag(None, "ci-base", "latest")
'ci-base:latest'
>>> build_full_tag("git.example.com", "ci-base", "0.1.0")
'git.example.com/ci-base:0.1.0'
"""
if registry:
return f"{registry}/{name}:{tag}"
return f"{name}:{tag}"
def registry_login(
registry: str,
username: str,
token: str,
*,
dry_run: bool = False,
) -> bool:
"""Log in to a Docker registry.
Returns True on success, False on failure.
In dry-run mode, prints the command without executing.
"""
cmd = ["docker", "login", registry, "-u", username, "--password-stdin"]
if dry_run:
click.echo(f"[dry-run] {' '.join(cmd)}")
return True
result = subprocess.run( # nosec B603
cmd,
input=token,
text=True,
capture_output=True,
check=False,
)
if result.returncode != 0:
click.echo(
_("Registry login failed: {error}", error=result.stderr.strip()),
err=True,
)
return False
click.echo(f"Logged in to {registry}")
return True
def build_image(
spec: ImageSpec,
registry: str | None = None,
*,
dry_run: bool = False,
pull: bool = False,
) -> bool:
"""Build a Docker image from a Dockerfile.
Tags the image with all specified tags, optionally prefixed with the
registry. Returns True on success, False on failure.
"""
if not Path(spec.dockerfile).is_file():
click.echo(
_("Dockerfile not found: {path}", path=spec.dockerfile),
err=True,
)
return False
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
cmd = ["docker", "build"]
if pull:
cmd.append("--pull")
for ft in full_tags:
cmd.extend(["-t", ft])
cmd.extend(["-f", spec.dockerfile, spec.context])
if dry_run:
click.echo(f"[dry-run] {' '.join(cmd)}")
return True
click.echo(f"Building {spec.name} ({len(full_tags)} tag(s))...")
result = subprocess.run( # nosec B603
cmd,
check=False,
)
if result.returncode != 0:
click.echo(_("Build failed for {name}", name=spec.name), err=True)
return False
click.echo(f"Built {spec.name}")
return True
def push_image(
spec: ImageSpec,
registry: str,
*,
dry_run: bool = False,
) -> bool:
"""Push all tags of a Docker image to the registry.
Returns True if all pushes succeed, False if any fail.
"""
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
all_ok = True
for ft in full_tags:
cmd = ["docker", "push", ft]
if dry_run:
click.echo(f"[dry-run] {' '.join(cmd)}")
continue
click.echo(f"Pushing {ft}...")
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
click.echo(
_("Push failed for {tag}: {error}", tag=ft, error=result.stderr.strip()),
err=True,
)
all_ok = False
else:
click.echo(f"Pushed {ft}")
return all_ok
def _get_registry_creds() -> tuple[str, str]:
"""Get registry credentials from environment variables.
Supports both REPO_TOKEN/GITEA_REGISTRY_TOKEN and
REGISTRY_USERNAME/GITEA_REGISTRY_USERNAME patterns.
"""
token = os.environ.get("REPO_TOKEN") or os.environ.get("GITEA_REGISTRY_TOKEN", "")
username = os.environ.get("REGISTRY_USERNAME") or os.environ.get("GITEA_REGISTRY_USERNAME", "")
return username, token
@click.command()
@click.option(
"--dockerfile",
"dockerfile",
default=None,
help="Path to Dockerfile (for single-image build).",
)
@click.option(
"--context",
"context",
default=".",
help="Build context directory (for single-image build).",
)
@click.option(
"--name",
"name",
default=None,
help="Image name (for single-image build).",
)
@click.option(
"--tag",
"tags",
multiple=True,
help="Tag(s) for the image. Can be repeated. Defaults to 'latest'.",
)
@click.option(
"--manifest",
"manifest",
default=None,
help="Path to JSON manifest file listing images to build.",
)
@click.option(
"--registry",
"registry",
default=None,
help="Registry URL (e.g. git.example.com). If set with --push, images are tagged and pushed there.",
)
@click.option(
"--push",
is_flag=True,
default=False,
help="Push images to the registry after building.",
)
@click.option(
"--dry-run",
is_flag=True,
default=False,
help="Print commands without executing.",
)
@click.option(
"--pull",
is_flag=True,
default=False,
help="Pass --pull to docker build (always fetch latest base image).",
)
def main(
dockerfile: str | None,
context: str,
name: str | None,
tags: tuple[str, ...],
manifest: str | None,
registry: str | None,
push: bool,
dry_run: bool,
pull: bool,
) -> None:
"""Build and optionally push Docker images to a Gitea registry."""
if manifest:
specs = load_manifest(manifest)
elif dockerfile and name:
tag_list = list(tags) if tags else ["latest"]
specs = [ImageSpec(name=name, dockerfile=dockerfile, context=context, tags=tag_list)]
else:
raise click.ClickException(_("Provide --manifest or both --dockerfile and --name"))
if push:
if not registry:
raise click.ClickException(_("--push requires --registry"))
username, token = _get_registry_creds()
if not token or not username:
raise click.ClickException(
_("Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars")
)
if not registry_login(registry, username, token, dry_run=dry_run):
raise click.ClickException(_("Registry login failed"))
failed: list[str] = []
for spec in specs:
if not build_image(spec, registry, dry_run=dry_run, pull=pull):
failed.append(spec.name)
continue
if push and not push_image(spec, registry, dry_run=dry_run): # type: ignore[arg-type]
failed.append(spec.name)
if failed:
raise click.ClickException(_("Failed images: {names}", names=", ".join(failed)))
click.echo(f"\nDone. {len(specs)} image(s) processed.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+8
View File
@@ -117,6 +117,7 @@ def _check_file(
legitimate_indicators: list[str],
repo_path_prefixes: list[str],
min_path_ref_length: int,
skip_ref_prefixes: list[str],
) -> list[str]:
"""Check a single file for stale references."""
issues: list[str] = []
@@ -147,6 +148,9 @@ def _check_file(
# Only check references that look like repo paths
if not any(ref.startswith(prefix) for prefix in repo_path_prefixes):
continue
# Skip references matching configured skip prefixes (e.g. aspirational test files)
if any(ref.startswith(prefix) for prefix in skip_ref_prefixes):
continue
candidate = repo_root / ref
if not candidate.exists():
issues.append(f"{rel_path}:{lineno}: references non-existent file '{ref}'")
@@ -179,6 +183,9 @@ def cli() -> None:
min_len_raw = cfg.get("min_path_ref_length")
min_path_ref_length: int = int(min_len_raw) if isinstance(min_len_raw, int) else MIN_PATH_REF_LENGTH_DEFAULT
skip_prefixes_raw = cfg.get("skip_ref_prefixes", [])
skip_ref_prefixes: list[str] = [str(d) for d in skip_prefixes_raw] if isinstance(skip_prefixes_raw, list) else []
deleted_files: set[str] = set()
deleted_raw = cfg.get("deleted_files", [])
if isinstance(deleted_raw, list):
@@ -209,6 +216,7 @@ def cli() -> None:
legitimate_indicators,
repo_path_prefixes,
min_path_ref_length,
skip_ref_prefixes,
)
all_issues.extend(issues)
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""Clean up old Docker images from a Gitea container registry.
Queries the Gitea API for all versions of a package (container type) and
deletes all but the most recent N versions. The ``latest`` tag is always
preserved if present.
Usage::
# Clean up ci-base images, keep last 2 versions
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--keep 2
# Clean up multiple images
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--name ci-quality \\
--name ci-full \\
--keep 2
# Dry run (list what would be deleted)
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--keep 2 \\
--dry-run
Authentication uses ``REPO_TOKEN`` environment variable.
"""
from __future__ import annotations
import os
from typing import Any
import click
import requests
from devx.config import GITEA_API_URL
from devx.i18n import _
def list_package_versions(
api_url: str,
owner: str,
name: str,
token: str,
*,
timeout: int = 30,
) -> list[dict[str, Any]]:
"""List all versions of a container package from the Gitea API.
Returns a list of version dicts, each containing at least ``version``
and ``created_at`` fields.
"""
url = f"{api_url}/packages/{owner}?type=container&name={name}"
headers = {"Authorization": f"token {token}"}
all_versions: list[dict[str, Any]] = []
page = 1
while True:
resp = requests.get(
f"{url}&page={page}&limit=50",
headers=headers,
timeout=timeout,
)
resp.raise_for_status()
data = resp.json()
if not data:
break
all_versions.extend(data)
if len(data) < 50:
break
page += 1
return all_versions
def delete_package_version(
api_url: str,
owner: str,
name: str,
version: str,
token: str,
*,
timeout: int = 30,
) -> bool:
"""Delete a specific version of a container package.
Returns True on success, False on failure.
"""
url = f"{api_url}/packages/{owner}/{name}/{version}"
headers = {"Authorization": f"token {token}"}
resp = requests.delete(url, headers=headers, timeout=timeout)
return resp.status_code in (204, 200)
def sort_versions_by_date(
versions: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Sort package versions by creation date, newest first.
Falls back to version string comparison if created_at is missing.
"""
def _sort_key(v: dict[str, Any]) -> str:
return str(v.get("created_at", v.get("version", "")))
return sorted(versions, key=_sort_key, reverse=True)
def select_for_deletion(
versions: list[dict[str, Any]],
keep: int,
) -> list[dict[str, Any]]:
"""Select versions to delete, keeping the most recent ``keep`` versions.
Versions named ``latest`` are always preserved.
"""
sorted_versions = sort_versions_by_date(versions)
to_delete = sorted_versions[keep:]
# Always preserve 'latest' tag
to_delete = [v for v in to_delete if v.get("version") != "latest"]
return to_delete
@click.command()
@click.option(
"--owner",
required=True,
help="Package owner (user or org).",
)
@click.option(
"--name",
"names",
multiple=True,
required=True,
help="Package name(s). Can be repeated.",
)
@click.option(
"--keep",
default=2,
type=int,
show_default=True,
help="Number of recent versions to keep (excluding 'latest').",
)
@click.option(
"--dry-run",
is_flag=True,
default=False,
help="List versions that would be deleted without actually deleting.",
)
@click.option(
"--api-url",
default=None,
help="Gitea API URL (defaults to DEVX_GITEA_API_URL or built-in default).",
)
def main(
owner: str,
names: tuple[str, ...],
keep: int,
dry_run: bool,
api_url: str | None,
) -> None:
"""Clean up old Docker image versions from a Gitea registry."""
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("REPO_TOKEN environment variable required"))
base_url = api_url or GITEA_API_URL
total_deleted = 0
total_kept = 0
for name in names:
click.echo(f"\n{'=' * 60}")
click.echo(f"Package: {owner}/{name}")
click.echo(f"{'=' * 60}")
try:
versions = list_package_versions(base_url, owner, name, token)
except requests.RequestException as exc:
click.echo(
_("Failed to list versions for {name}: {error}", name=name, error=exc),
err=True,
)
continue
if not versions:
click.echo(_("No versions found."))
continue
click.echo(f"Found {len(versions)} version(s):")
for v in sort_versions_by_date(versions):
click.echo(f" {v.get('version', '?')} (created: {v.get('created_at', '?')})")
to_delete = select_for_deletion(versions, keep)
kept_count = len(versions) - len(to_delete)
click.echo(f"\nKeeping {kept_count}, would delete {len(to_delete)}")
if dry_run:
for v in to_delete:
click.echo(f" [dry-run] Would delete: {v.get('version', '?')}")
total_kept += kept_count
continue
deleted_count = 0
for v in to_delete:
version = str(v.get("version", ""))
if delete_package_version(base_url, owner, name, version, token):
click.echo(f" Deleted: {version}")
deleted_count += 1
else:
click.echo(f" FAILED to delete: {version}", err=True)
total_deleted += deleted_count
total_kept += kept_count
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+525 -373
View File
@@ -343,6 +343,14 @@
"ru": " Updated: {title}",
"zh": " Updated: {title}"
},
"--push requires --registry": {
"bg": "--push requires --registry",
"de": "--push requires --registry",
"en": "--push requires --registry",
"pl": "--push requires --registry",
"ru": "--push requires --registry",
"zh": "--push requires --registry"
},
"--skip-build: skipping package build and PyPI publish.": {
"bg": "--skip-build: skipping package build and PyPI publish.",
"de": "--skip-build: skipping package build and PyPI publish.",
@@ -367,6 +375,14 @@
"ru": "API poll warning: {exc}",
"zh": "API poll warning: {exc}"
},
"Additional directory to scan (default: scripts, tests). Can be repeated.": {
"bg": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"de": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"en": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"pl": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"zh": "Additional directory to scan (default: scripts, tests). Can be repeated."
},
"All molecule tests passed.": {
"bg": "All molecule tests passed.",
"de": "All molecule tests passed.",
@@ -383,6 +399,22 @@
"ru": "Another molecule runner failed. Stopping this runner early.",
"zh": "Another molecule runner failed. Stopping this runner early."
},
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": {
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание",
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung",
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description",
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis",
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание",
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述"
},
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"": {
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание\n Пример: {prefix}-42-add-feature\n Решение: преименувайте клона или създайте Vikunja задача:\n python -m devx.tools.create_task --title \"Заглавие на задача\"",
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung\n Beispiel: {prefix}-42-add-feature\n Fix: Branch umbenennen oder Vikunja-Task erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"",
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"",
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis\n Przykład: {prefix}-42-add-feature\n Naprawa: zmień nazwę gałęzi lub utwórz zadanie Vikunja:\n python -m devx.tools.create_task --title \"Tytuł zadania\"",
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"",
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\""
},
"Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
"bg": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"de": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
@@ -391,6 +423,38 @@
"ru": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"zh": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
},
"Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": {
"bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"en": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"pl": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"ru": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"zh": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master"
},
"Branch name (e.g., DEVX-256-fix-foo)": {
"bg": "Branch name (e.g., DEVX-256-fix-foo)",
"de": "Branch name (e.g., DEVX-256-fix-foo)",
"en": "Branch name (e.g., DEVX-256-fix-foo)",
"pl": "Branch name (e.g., DEVX-256-fix-foo)",
"ru": "Branch name (e.g., DEVX-256-fix-foo)",
"zh": "Branch name (e.g., DEVX-256-fix-foo)"
},
"Branch name must contain a task ID.": {
"bg": "Branch name must contain a task ID.",
"de": "Branch name must contain a task ID.",
"en": "Branch name must contain a task ID.",
"pl": "Branch name must contain a task ID.",
"ru": "Branch name must contain a task ID.",
"zh": "Branch name must contain a task ID."
},
"Build failed for {name}": {
"bg": "Build failed for {name}",
"de": "Build failed for {name}",
"en": "Build failed for {name}",
"pl": "Build failed for {name}",
"ru": "Build failed for {name}",
"zh": "Build failed for {name}"
},
"Bumping version: {current} -> v{new_version}": {
"bg": "Bumping version: {current} -> v{new_version}",
"de": "Bumping version: {current} -> v{new_version}",
@@ -399,6 +463,14 @@
"ru": "Bumping version: {current} -> v{new_version}",
"zh": "Bumping version: {current} -> v{new_version}"
},
"Check that changed files have corresponding tests": {
"bg": "Check that changed files have corresponding tests",
"de": "Check that changed files have corresponding tests",
"en": "Check that changed files have corresponding tests",
"pl": "Check that changed files have corresponding tests",
"ru": "Check that changed files have corresponding tests",
"zh": "Check that changed files have corresponding tests"
},
"Checking CLI command documentation...": {
"bg": "Checking CLI command documentation...",
"de": "Checking CLI command documentation...",
@@ -423,6 +495,14 @@
"ru": "Comparing {base}..{head} ({count} files changed)",
"zh": "Comparing {base}..{head} ({count} files changed)"
},
"Configuration OK: [tool.devx] present, devx versions consistent.": {
"bg": "Конфигурацията е OK: [tool.devx] присъства, версиите на devx са консистентни.",
"de": "Konfiguration OK: [tool.devx] vorhanden, devx-Versionen konsistent.",
"en": "Configuration OK: [tool.devx] present, devx versions consistent.",
"pl": "Konfiguracja OK: [tool.devx] obecne, wersje devx spójne.",
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.",
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
},
"Configuring branch protection for {branch}...": {
"bg": "Конфигуриране на защита на клона {branch}...",
"de": "Konfiguriere Branch-Schutz für {branch}...",
@@ -439,13 +519,13 @@
"ru": "Настройка параметров репозитория...",
"zh": "正在配置仓库设置..."
},
"Configuration OK: [tool.devx] present, devx versions consistent.": {
"bg": "Конфигурацията е OK: [tool.devx] присъства, версиите на devx са консистентни.",
"de": "Konfiguration OK: [tool.devx] vorhanden, devx-Versionen konsistent.",
"en": "Configuration OK: [tool.devx] present, devx versions consistent.",
"pl": "Konfiguracja OK: [tool.devx] obecne, wersje devx spójne.",
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.",
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
"Could not detect current branch: {error}": {
"bg": "Не може да се определи текущия клон: {error}",
"de": "Aktueller Branch konnte nicht erkannt werden: {error}",
"en": "Could not detect current branch: {error}",
"pl": "Nie można wykryć bieżącej gałęzi: {error}",
"ru": "Не удалось определить текущую ветку: {error}",
"zh": "无法检测当前分支: {error}"
},
"Could not extract conventional commit message from PR commits.": {
"bg": "Could not extract conventional commit message from PR commits.",
@@ -455,6 +535,22 @@
"ru": "Could not extract conventional commit message from PR commits.",
"zh": "Could not extract conventional commit message from PR commits."
},
"Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).": {
"bg": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"de": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"en": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"pl": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"ru": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"zh": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found)."
},
"Could not find Vikunja task {task_id} in project {project_id}.": {
"bg": "Не е намерена Vikunja задача {task_id} в проект {project_id}.",
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.",
"en": "Could not find Vikunja task {task_id} in project {project_id}.",
"pl": "Nie znaleziono zadania Vikunja {task_id} w projekcie {project_id}.",
"ru": "Не найдена задача Vikunja {task_id} в проекте {project_id}.",
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。"
},
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
"bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
@@ -479,6 +575,22 @@
"ru": "Could not parse test execution time from output.",
"zh": "Could not parse test execution time from output."
},
"Created PR #{index}: {title}\n {url}": {
"bg": "Създаден PR #{index}: {title}\n {url}",
"de": "PR erstellt #{index}: {title}\n {url}",
"en": "Created PR #{index}: {title}\n {url}",
"pl": "Utworzono PR #{index}: {title}\n {url}",
"ru": "Создан PR #{index}: {title}\n {url}",
"zh": "已创建 PR #{index}: {title}\n {url}"
},
"Created Vikunja task: {identifier} (id={task_id})": {
"bg": "Създадена Vikunja задача: {identifier} (id={task_id})",
"de": "Vikunja-Task erstellt: {identifier} (id={task_id})",
"en": "Created Vikunja task: {identifier} (id={task_id})",
"pl": "Utworzono zadanie Vikunja: {identifier} (id={task_id})",
"ru": "Создана задача Vikunja: {identifier} (id={task_id})",
"zh": "已创建 Vikunja 任务: {identifier} (id={task_id})"
},
"Created issue #{issue_id}: {title}": {
"bg": "Created issue #{issue_id}: {title}",
"de": "Created issue #{issue_id}: {title}",
@@ -495,13 +607,13 @@
"ru": "Created release commit.",
"zh": "Created release commit."
},
"devx version mismatch across extras: {detail}": {
"bg": "несъответствие на версията на devx между extras: {detail}",
"de": "devx-Versionskonflikt zwischen Extras: {detail}",
"en": "devx version mismatch across extras: {detail}",
"pl": "niezgodność wersji devx między extras: {detail}",
"ru": "несоответствие версии devx между extras: {detail}",
"zh": "devx 版本在 extras 之间不一致: {detail}"
"Dependencies must have documentation comments.": {
"bg": "Dependencies must have documentation comments.",
"de": "Dependencies must have documentation comments.",
"en": "Dependencies must have documentation comments.",
"pl": "Dependencies must have documentation comments.",
"ru": "Dependencies must have documentation comments.",
"zh": "Dependencies must have documentation comments."
},
"Docker daemon already running": {
"bg": "Докер демонът вече работи",
@@ -527,6 +639,14 @@
"ru": "Docker-демон запущен",
"zh": "Docker 守护进程已启动"
},
"Dockerfile not found: {path}": {
"bg": "Dockerfile not found: {path}",
"de": "Dockerfile not found: {path}",
"en": "Dockerfile not found: {path}",
"pl": "Dockerfile not found: {path}",
"ru": "Dockerfile not found: {path}",
"zh": "Dockerfile not found: {path}"
},
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
@@ -575,6 +695,14 @@
"ru": "ERROR: mapping.json not found at {path}",
"zh": "ERROR: mapping.json not found at {path}"
},
"FAILED: {count} undocumented dependency/ies": {
"bg": "FAILED: {count} undocumented dependency/ies",
"de": "FAILED: {count} undocumented dependency/ies",
"en": "FAILED: {count} undocumented dependency/ies",
"pl": "FAILED: {count} undocumented dependency/ies",
"ru": "FAILED: {count} undocumented dependency/ies",
"zh": "FAILED: {count} undocumented dependency/ies"
},
"FAILED: {pair} exited with code {code}": {
"bg": "FAILED: {pair} exited with code {code}",
"de": "FAILED: {pair} exited with code {code}",
@@ -583,6 +711,14 @@
"ru": "FAILED: {pair} exited with code {code}",
"zh": "FAILED: {pair} exited with code {code}"
},
"Failed images: {names}": {
"bg": "Failed images: {names}",
"de": "Failed images: {names}",
"en": "Failed images: {names}",
"pl": "Failed images: {names}",
"ru": "Failed images: {names}",
"zh": "Failed images: {names}"
},
"Failed to create issue via tea: {error}": {
"bg": "Failed to create issue via tea: {error}",
"de": "Failed to create issue via tea: {error}",
@@ -591,6 +727,14 @@
"ru": "Failed to create issue via tea: {error}",
"zh": "Failed to create issue via tea: {error}"
},
"Failed to list versions for {name}: {error}": {
"bg": "Failed to list versions for {name}: {error}",
"de": "Failed to list versions for {name}: {error}",
"en": "Failed to list versions for {name}: {error}",
"pl": "Failed to list versions for {name}: {error}",
"ru": "Failed to list versions for {name}: {error}",
"zh": "Failed to list versions for {name}: {error}"
},
"Found {count} existing wiki pages.": {
"bg": "Found {count} existing wiki pages.",
"de": "Found {count} existing wiki pages.",
@@ -599,6 +743,22 @@
"ru": "Found {count} existing wiki pages.",
"zh": "Found {count} existing wiki pages."
},
"Found {count} mutable global(s) — use factory functions or pytest fixtures.": {
"bg": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"de": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"en": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"pl": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"ru": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"zh": "Found {count} mutable global(s) — use factory functions or pytest fixtures."
},
"Found {count} stale documentation reference(s)": {
"bg": "Found {count} stale documentation reference(s)",
"de": "Found {count} stale documentation reference(s)",
"en": "Found {count} stale documentation reference(s)",
"pl": "Found {count} stale documentation reference(s)",
"ru": "Found {count} stale documentation reference(s)",
"zh": "Found {count} stale documentation reference(s)"
},
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
"bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
@@ -687,6 +847,30 @@
"ru": "Хост Docker недоступен, запускается локальный dockerd...",
"zh": "主机 Docker 不可用,正在启动本地 dockerd..."
},
"Image 'tags' must be a list": {
"bg": "Image 'tags' must be a list",
"de": "Image 'tags' must be a list",
"en": "Image 'tags' must be a list",
"pl": "Image 'tags' must be a list",
"ru": "Image 'tags' must be a list",
"zh": "Image 'tags' must be a list"
},
"Image manifest entry missing 'dockerfile'": {
"bg": "Image manifest entry missing 'dockerfile'",
"de": "Image manifest entry missing 'dockerfile'",
"en": "Image manifest entry missing 'dockerfile'",
"pl": "Image manifest entry missing 'dockerfile'",
"ru": "Image manifest entry missing 'dockerfile'",
"zh": "Image manifest entry missing 'dockerfile'"
},
"Image manifest entry missing 'name'": {
"bg": "Image manifest entry missing 'name'",
"de": "Image manifest entry missing 'name'",
"en": "Image manifest entry missing 'name'",
"pl": "Image manifest entry missing 'name'",
"ru": "Image manifest entry missing 'name'",
"zh": "Image manifest entry missing 'name'"
},
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
"bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}",
"de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}",
@@ -735,6 +919,22 @@
"ru": "Lint passed.",
"zh": "Lint passed."
},
"Manifest file not found: {path}": {
"bg": "Manifest file not found: {path}",
"de": "Manifest file not found: {path}",
"en": "Manifest file not found: {path}",
"pl": "Manifest file not found: {path}",
"ru": "Manifest file not found: {path}",
"zh": "Manifest file not found: {path}"
},
"Manifest must be a JSON list": {
"bg": "Manifest must be a JSON list",
"de": "Manifest must be a JSON list",
"en": "Manifest must be a JSON list",
"pl": "Manifest must be a JSON list",
"ru": "Manifest must be a JSON list",
"zh": "Manifest must be a JSON list"
},
"Mapped file {file} is empty. Update the content or remove from mapping.json.": {
"bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"de": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
@@ -775,6 +975,14 @@
"ru": "Директория molecule не найдена: {path}",
"zh": "未找到 molecule 目录: {path}"
},
"Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})": {
"bg": "Следващи стъпки:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-кратко-описание\n 3. Имплементирайте промените, commit с conventional commit формат\n 4. git push -u origin HEAD\n 5. make create-pr (създава PR с заглавие: {identifier}: {title})",
"de": "Nächste Schritte:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-kurz-beschreibung\n 3. Änderungen implementieren, mit Conventional-Commit-Format committen\n 4. git push -u origin HEAD\n 5. make create-pr (erstellt PR mit Titel: {identifier}: {title})",
"en": "Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})",
"pl": "Następne kroki:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-krótki-opis\n 3. Wprowadź zmiany, commituj w formacie conventional commit\n 4. git push -u origin HEAD\n 5. make create-pr (tworzy PR z tytułem: {identifier}: {title})",
"ru": "Следующие шаги:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-краткое-описание\n 3. Реализуйте изменения, коммитьте в conventional commit формате\n 4. git push -u origin HEAD\n 5. make create-pr (создаёт PR с заголовком: {identifier}: {title})",
"zh": "后续步骤:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-简短描述\n 3. 实现更改,使用 conventional commit 格式提交\n 4. git push -u origin HEAD\n 5. make create-pr (创建 PR,标题: {identifier}: {title})"
},
"Nice! Gitea release {tag} created.": {
"bg": "Отлично! Gitea release {tag} е създаден.",
"de": "Prima! Gitea-Release {tag} erstellt.",
@@ -847,6 +1055,14 @@
"ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID."
},
"No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.": {
"bg": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"de": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"en": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"pl": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"ru": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"zh": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description."
},
"No unreleased changes found. Nothing to release.": {
"bg": "No unreleased changes found. Nothing to release.",
"de": "No unreleased changes found. Nothing to release.",
@@ -863,6 +1079,14 @@
"ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
},
"No versions found.": {
"bg": "No versions found.",
"de": "No versions found.",
"en": "No versions found.",
"pl": "No versions found.",
"ru": "No versions found.",
"zh": "No versions found."
},
"Note: Self-approval not allowed. Posting COMMENT instead.": {
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
"de": "Note: Self-approval not allowed. Posting COMMENT instead.",
@@ -871,6 +1095,14 @@
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.",
"zh": "Note: Self-approval not allowed. Posting COMMENT instead."
},
"Only check staged files (for pre-commit)": {
"bg": "Only check staged files (for pre-commit)",
"de": "Only check staged files (for pre-commit)",
"en": "Only check staged files (for pre-commit)",
"pl": "Only check staged files (for pre-commit)",
"ru": "Only check staged files (for pre-commit)",
"zh": "Only check staged files (for pre-commit)"
},
"Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": {
"bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
@@ -959,6 +1191,22 @@
"ru": "PASSED: {pair}",
"zh": "PASSED: {pair}"
},
"PR already exists: #{index} — {url}": {
"bg": "PR вече съществува: #{index} — {url}",
"de": "PR existiert bereits: #{index} — {url}",
"en": "PR already exists: #{index} — {url}",
"pl": "PR już istnieje: #{index} — {url}",
"ru": "PR уже существует: #{index} — {url}",
"zh": "PR 已存在: #{index} — {url}"
},
"PR number (to fetch title from Gitea)": {
"bg": "PR number (to fetch title from Gitea)",
"de": "PR number (to fetch title from Gitea)",
"en": "PR number (to fetch title from Gitea)",
"pl": "PR number (to fetch title from Gitea)",
"ru": "PR number (to fetch title from Gitea)",
"zh": "PR number (to fetch title from Gitea)"
},
"PR number must be an integer, got: {pr_number}": {
"bg": "PR number must be an integer, got: {pr_number}",
"de": "PR number must be an integer, got: {pr_number}",
@@ -967,6 +1215,14 @@
"ru": "PR number must be an integer, got: {pr_number}",
"zh": "PR number must be an integer, got: {pr_number}"
},
"PR title (auto-fetched if --pr-number given)": {
"bg": "PR title (auto-fetched if --pr-number given)",
"de": "PR title (auto-fetched if --pr-number given)",
"en": "PR title (auto-fetched if --pr-number given)",
"pl": "PR title (auto-fetched if --pr-number given)",
"ru": "PR title (auto-fetched if --pr-number given)",
"zh": "PR title (auto-fetched if --pr-number given)"
},
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
@@ -975,6 +1231,30 @@
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
},
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}": {
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"pl": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}"
},
"PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}": {
"bg": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"de": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"en": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"pl": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"ru": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"zh": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}"
},
"PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}": {
"bg": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"de": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"en": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"pl": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"ru": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"zh": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}"
},
"PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
"bg": "PYPI_TOKEN не е зададен и няма конфигуриран URL на registry — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.",
"de": "PYPI_TOKEN nicht gesetzt und keine Registry-URL konfiguriert — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
@@ -991,6 +1271,14 @@
"ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME",
"zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}"
},
"Path to pyproject.toml (default: pyproject.toml in CWD).": {
"bg": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"de": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"en": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"pl": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"ru": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"zh": "Path to pyproject.toml (default: pyproject.toml in CWD)."
},
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.": {
"bg": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"de": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
@@ -999,6 +1287,38 @@
"ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
},
"Pre-merge validation failed.": {
"bg": "Pre-merge validation failed.",
"de": "Pre-merge validation failed.",
"en": "Pre-merge validation failed.",
"pl": "Pre-merge validation failed.",
"ru": "Pre-merge validation failed.",
"zh": "Pre-merge validation failed."
},
"Pre-push check passed: task {task_id} exists.": {
"bg": "Pre-push проверката премина: задача {task_id} съществува.",
"de": "Pre-push-Prüfung bestanden: Task {task_id} existiert.",
"en": "Pre-push check passed: task {task_id} exists.",
"pl": "Sprawdzanie pre-push zakończone: zadanie {task_id} istnieje.",
"ru": "Pre-push проверка пройдена: задача {task_id} существует.",
"zh": "Pre-push 检查通过: 任务 {task_id} 存在。"
},
"Print warnings but always exit 0": {
"bg": "Print warnings but always exit 0",
"de": "Print warnings but always exit 0",
"en": "Print warnings but always exit 0",
"pl": "Print warnings but always exit 0",
"ru": "Print warnings but always exit 0",
"zh": "Print warnings but always exit 0"
},
"Provide --manifest or both --dockerfile and --name": {
"bg": "Provide --manifest or both --dockerfile and --name",
"de": "Provide --manifest or both --dockerfile and --name",
"en": "Provide --manifest or both --dockerfile and --name",
"pl": "Provide --manifest or both --dockerfile and --name",
"ru": "Provide --manifest or both --dockerfile and --name",
"zh": "Provide --manifest or both --dockerfile and --name"
},
"Provide a commit message file or use --git.": {
"bg": "Provide a commit message file or use --git.",
"de": "Provide a commit message file or use --git.",
@@ -1031,6 +1351,14 @@
"ru": "Publishing release {tag}...",
"zh": "Publishing release {tag}..."
},
"Push failed for {tag}: {error}": {
"bg": "Push failed for {tag}: {error}",
"de": "Push failed for {tag}: {error}",
"en": "Push failed for {tag}: {error}",
"pl": "Push failed for {tag}: {error}",
"ru": "Push failed for {tag}: {error}",
"zh": "Push failed for {tag}: {error}"
},
"Pushed release commit to master.": {
"bg": "Pushed release commit to master.",
"de": "Pushed release commit to master.",
@@ -1047,6 +1375,54 @@
"ru": "Публикация в PyPI не удалась (некритично — продолжаем создание Gitea release):\n{error}",
"zh": "PyPI 发布失败(非致命 — 继续创建 Gitea release):\n{error}"
},
"REPO argument is required (or set GITHUB_REPOSITORY env var).": {
"bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"de": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"en": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).",
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
},
"REPO_TOKEN environment variable required": {
"bg": "REPO_TOKEN environment variable required",
"de": "REPO_TOKEN environment variable required",
"en": "REPO_TOKEN environment variable required",
"pl": "REPO_TOKEN environment variable required",
"ru": "REPO_TOKEN environment variable required",
"zh": "REPO_TOKEN environment variable required"
},
"REPO_TOKEN is not set. Required to create a PR.": {
"bg": "REPO_TOKEN не е зададен. Необходим за създаване на PR.",
"de": "REPO_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
"en": "REPO_TOKEN is not set. Required to create a PR.",
"pl": "REPO_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
"ru": "REPO_TOKEN не установлен. Требуется для создания PR.",
"zh": "REPO_TOKEN 未设置。创建 PR 所需。"
},
"Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars": {
"bg": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
"de": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
"en": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
"pl": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
"ru": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
"zh": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars"
},
"Registry login failed": {
"bg": "Registry login failed",
"de": "Registry login failed",
"en": "Registry login failed",
"pl": "Registry login failed",
"ru": "Registry login failed",
"zh": "Registry login failed"
},
"Registry login failed: {error}": {
"bg": "Registry login failed: {error}",
"de": "Registry login failed: {error}",
"en": "Registry login failed: {error}",
"pl": "Registry login failed: {error}",
"ru": "Registry login failed: {error}",
"zh": "Registry login failed: {error}"
},
"Release creation failed: {error}": {
"bg": "Release creation failed: {error}",
"de": "Release creation failed: {error}",
@@ -1079,6 +1455,30 @@
"ru": "Конфигурация репозитория завершена.",
"zh": "仓库配置完成。"
},
"Repository in owner/name format": {
"bg": "Repository in owner/name format",
"de": "Repository in owner/name format",
"en": "Repository in owner/name format",
"pl": "Repository in owner/name format",
"ru": "Repository in owner/name format",
"zh": "Repository in owner/name format"
},
"Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.": {
"bg": "Името на хранилището не е зададено. Използвайте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
"de": "Repository-Name nicht gesetzt. Verwende DEVX_REPO_NAME oder GITHUB_REPOSITORY env var.",
"en": "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.",
"pl": "Nazwa repozytorium nie jest ustawiona. Użyj DEVX_REPO_NAME lub GITHUB_REPOSITORY env var.",
"ru": "Имя репозитория не установлено. Используйте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
"zh": "仓库名称未设置。使用 DEVX_REPO_NAME 或 GITHUB_REPOSITORY 环境变量。"
},
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
"en": "Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.",
"pl": "Właściciel repozytorium nie jest ustawiony. Użyj --owner lub DEVX_REPO_OWNER env var.",
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
},
"Roles directory not found: {path}": {
"bg": "Roles directory not found: {path}",
"de": "Roles directory not found: {path}",
@@ -1119,6 +1519,22 @@
"ru": "Running: {scenario} on {platform}",
"zh": "Running: {scenario} on {platform}"
},
"Skip Vikunja title match check": {
"bg": "Skip Vikunja title match check",
"de": "Skip Vikunja title match check",
"en": "Skip Vikunja title match check",
"pl": "Skip Vikunja title match check",
"ru": "Skip Vikunja title match check",
"zh": "Skip Vikunja title match check"
},
"Skip branch-behind-master check": {
"bg": "Skip branch-behind-master check",
"de": "Skip branch-behind-master check",
"en": "Skip branch-behind-master check",
"pl": "Skip branch-behind-master check",
"ru": "Skip branch-behind-master check",
"zh": "Skip branch-behind-master check"
},
"Skipping commit push — no staged changes.": {
"bg": "Skipping commit push — no staged changes.",
"de": "Skipping commit push — no staged changes.",
@@ -1151,14 +1567,6 @@
"ru": "Tag is required (or use --from-tag).",
"zh": "Tag is required (or use --from-tag)."
},
"REPO argument is required (or set GITHUB_REPOSITORY env var).": {
"bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"de": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"en": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).",
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
},
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
@@ -1255,6 +1663,22 @@
"ru": "Updated {changelog_file}",
"zh": "Updated {changelog_file}"
},
"VIKUNJA_TOKEN is not set. Required to derive PR title.": {
"bg": "VIKUNJA_TOKEN не е зададен. Необходим за извличане на PR заглавие.",
"de": "VIKUNJA_TOKEN nicht gesetzt. Erforderlich zum Ableiten des PR-Titels.",
"en": "VIKUNJA_TOKEN is not set. Required to derive PR title.",
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Wymagany do pobrania tytułu PR.",
"ru": "VIKUNJA_TOKEN не установлен. Требуется для получения заголовка PR.",
"zh": "VIKUNJA_TOKEN 未设置。推导 PR 标题所需。"
},
"VIKUNJA_TOKEN is not set. Set it in .env or environment.": {
"bg": "VIKUNJA_TOKEN не е зададен. Задайте го в .env или средата.",
"de": "VIKUNJA_TOKEN nicht gesetzt. In .env oder Umgebung setzen.",
"en": "VIKUNJA_TOKEN is not set. Set it in .env or environment.",
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Ustaw go w .env lub środowisku.",
"ru": "VIKUNJA_TOKEN не установлен. Установите его в .env или среде.",
"zh": "VIKUNJA_TOKEN 未设置。在 .env 或环境中设置它。"
},
"VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": {
"bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
@@ -1279,6 +1703,14 @@
"ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update."
},
"Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.": {
"bg": "Vikunja задача {task_id} не е намерена в проект {project_id}.\n Създайте я първо:\n python -m devx.tools.create_task --title \"Заглавие на задача\"\n Или проверете че ID на задачата в името на клона е правилно.",
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.\n Zuerst erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"\n Oder prüfen, ob die Task-ID im Branch-Namen korrekt ist.",
"en": "Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.",
"pl": "Zadanie Vikunja {task_id} nie znalezione w projekcie {project_id}.\n Utwórz je najpierw:\n python -m devx.tools.create_task --title \"Tytuł zadania\"\n Lub sprawdź, czy ID zadania w nazwie gałęzi jest poprawne.",
"ru": "Задача Vikunja {task_id} не найдена в проекте {project_id}.\n Сначала создайте её:\n python -m devx.tools.create_task --title \"Заголовок задачи\"\n Или проверьте, что ID задачи в имени ветки корректен.",
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。\n 请先创建:\n python -m devx.tools.create_task --title \"任务标题\"\n 或检查分支名称中的任务 ID 是否正确。"
},
"WARNING: --skip-tests passed — skipping test verification.": {
"bg": "WARNING: --skip-tests passed — skipping test verification.",
"de": "WARNING: --skip-tests passed — skipping test verification.",
@@ -1295,6 +1727,14 @@
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
},
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
"en": "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.",
"pl": "OSTRZEŻENIE: VIKUNJA_TOKEN nie jest ustawiony — pomijanie sprawdzania istnienia zadania. Ustaw w .env, aby włączyć pełną walidację.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
},
"Warning: could not fetch tags from origin.": {
"bg": "Warning: could not fetch tags from origin.",
"de": "Warning: could not fetch tags from origin.",
@@ -1319,13 +1759,45 @@
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
},
"[tool.devx] missing required keys: {keys}": {
"bg": "[tool.devx] липсват задължителни ключове: {keys}",
"de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}",
"en": "[tool.devx] missing required keys: {keys}",
"pl": "[tool.devx] brak wymaganych kluczy: {keys}",
"ru": "[tool.devx] отсутствуют обязательные ключи: {keys}",
"zh": "[tool.devx] 缺少必需的键: {keys}"
"Wrote tag {tag} to GITHUB_OUTPUT.": {
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
"de": "Wrote tag {tag} to GITHUB_OUTPUT.",
"en": "Wrote tag {tag} to GITHUB_OUTPUT.",
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
"zh": "Wrote tag {tag} to GITHUB_OUTPUT.",
"pl": "Wrote tag {tag} to GITHUB_OUTPUT."
},
"[check-dep-docs] Passed: all dependencies are documented": {
"bg": "[check-dep-docs] Passed: all dependencies are documented",
"de": "[check-dep-docs] Passed: all dependencies are documented",
"en": "[check-dep-docs] Passed: all dependencies are documented",
"pl": "[check-dep-docs] Passed: all dependencies are documented",
"ru": "[check-dep-docs] Passed: all dependencies are documented",
"zh": "[check-dep-docs] Passed: all dependencies are documented"
},
"[check-mutable-globals] Passed: no mutable path globals found": {
"bg": "[check-mutable-globals] Passed: no mutable path globals found",
"de": "[check-mutable-globals] Passed: no mutable path globals found",
"en": "[check-mutable-globals] Passed: no mutable path globals found",
"pl": "[check-mutable-globals] Passed: no mutable path globals found",
"ru": "[check-mutable-globals] Passed: no mutable path globals found",
"zh": "[check-mutable-globals] Passed: no mutable path globals found"
},
"[check_agent_docs] Passed: scanned {count} file(s), no stale references": {
"bg": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"de": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"en": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"pl": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"ru": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"zh": "[check_agent_docs] Passed: scanned {count} file(s), no stale references"
},
"[check_test_coverage] No changed files to check.": {
"bg": "[check_test_coverage] No changed files to check.",
"de": "[check_test_coverage] No changed files to check.",
"en": "[check_test_coverage] No changed files to check.",
"pl": "[check_test_coverage] No changed files to check.",
"ru": "[check_test_coverage] No changed files to check.",
"zh": "[check_test_coverage] No changed files to check."
},
"[dry-run] Would commit: release: v{version}": {
"bg": "[dry-run] Would commit: release: v{version}",
@@ -1383,6 +1855,14 @@
"ru": "[dry-run] Would update {init}",
"zh": "[dry-run] Would update {init}"
},
"[tool.devx] missing required keys: {keys}": {
"bg": "[tool.devx] липсват задължителни ключове: {keys}",
"de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}",
"en": "[tool.devx] missing required keys: {keys}",
"pl": "[tool.devx] brak wymaganych kluczy: {keys}",
"ru": "[tool.devx] отсутствуют обязательные ключи: {keys}",
"zh": "[tool.devx] 缺少必需的键: {keys}"
},
"active": {
"bg": "активен",
"de": "aktiv",
@@ -1399,6 +1879,14 @@
"ru": "завершён",
"zh": "已完成"
},
"devx version mismatch across extras: {detail}": {
"bg": "несъответствие на версията на devx между extras: {detail}",
"de": "devx-Versionskonflikt zwischen Extras: {detail}",
"en": "devx version mismatch across extras: {detail}",
"pl": "niezgodność wersji devx między extras: {detail}",
"ru": "несоответствие версии devx между extras: {detail}",
"zh": "devx 版本在 extras 之间不一致: {detail}"
},
"failed": {
"bg": "неуспешен",
"de": "fehlgeschlagen",
@@ -1503,348 +1991,12 @@
"ru": "{file} already exists. Use --force to overwrite.",
"zh": "{file} already exists. Use --force to overwrite."
},
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": {
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание",
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung",
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description",
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis",
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание",
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述"
},
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"": {
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание\n Пример: {prefix}-42-add-feature\n Решение: преименувайте клона или създайте Vikunja задача:\n python -m devx.tools.create_task --title \"Заглавие на задача\"",
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung\n Beispiel: {prefix}-42-add-feature\n Fix: Branch umbenennen oder Vikunja-Task erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"",
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"",
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis\n Przykład: {prefix}-42-add-feature\n Naprawa: zmień nazwę gałęzi lub utwórz zadanie Vikunja:\n python -m devx.tools.create_task --title \"Tytuł zadania\"",
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"",
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\""
},
"Could not find Vikunja task {task_id} in project {project_id}.": {
"bg": "Не е намерена Vikunja задача {task_id} в проект {project_id}.",
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.",
"en": "Could not find Vikunja task {task_id} in project {project_id}.",
"pl": "Nie znaleziono zadania Vikunja {task_id} w projekcie {project_id}.",
"ru": "Не найдена задача Vikunja {task_id} в проекте {project_id}.",
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。"
},
"Could not detect current branch: {error}": {
"bg": "Не може да се определи текущия клон: {error}",
"de": "Aktueller Branch konnte nicht erkannt werden: {error}",
"en": "Could not detect current branch: {error}",
"pl": "Nie można wykryć bieżącej gałęzi: {error}",
"ru": "Не удалось определить текущую ветку: {error}",
"zh": "无法检测当前分支: {error}"
},
"Created PR #{index}: {title}\n {url}": {
"bg": "Създаден PR #{index}: {title}\n {url}",
"de": "PR erstellt #{index}: {title}\n {url}",
"en": "Created PR #{index}: {title}\n {url}",
"pl": "Utworzono PR #{index}: {title}\n {url}",
"ru": "Создан PR #{index}: {title}\n {url}",
"zh": "已创建 PR #{index}: {title}\n {url}"
},
"Created Vikunja task: {identifier} (id={task_id})": {
"bg": "Създадена Vikunja задача: {identifier} (id={task_id})",
"de": "Vikunja-Task erstellt: {identifier} (id={task_id})",
"en": "Created Vikunja task: {identifier} (id={task_id})",
"pl": "Utworzono zadanie Vikunja: {identifier} (id={task_id})",
"ru": "Создана задача Vikunja: {identifier} (id={task_id})",
"zh": "已创建 Vikunja 任务: {identifier} (id={task_id})"
},
"Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})": {
"bg": "Следващи стъпки:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-кратко-описание\n 3. Имплементирайте промените, commit с conventional commit формат\n 4. git push -u origin HEAD\n 5. make create-pr (създава PR с заглавие: {identifier}: {title})",
"de": "Nächste Schritte:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-kurz-beschreibung\n 3. Änderungen implementieren, mit Conventional-Commit-Format committen\n 4. git push -u origin HEAD\n 5. make create-pr (erstellt PR mit Titel: {identifier}: {title})",
"en": "Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})",
"pl": "Następne kroki:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-krótki-opis\n 3. Wprowadź zmiany, commituj w formacie conventional commit\n 4. git push -u origin HEAD\n 5. make create-pr (tworzy PR z tytułem: {identifier}: {title})",
"ru": "Следующие шаги:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-краткое-описание\n 3. Реализуйте изменения, коммитьте в conventional commit формате\n 4. git push -u origin HEAD\n 5. make create-pr (создаёт PR с заголовком: {identifier}: {title})",
"zh": "后续步骤:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-简短描述\n 3. 实现更改,使用 conventional commit 格式提交\n 4. git push -u origin HEAD\n 5. make create-pr (创建 PR,标题: {identifier}: {title})"
},
"PR already exists: #{index} — {url}": {
"bg": "PR вече съществува: #{index} — {url}",
"de": "PR existiert bereits: #{index} — {url}",
"en": "PR already exists: #{index} — {url}",
"pl": "PR już istnieje: #{index} — {url}",
"ru": "PR уже существует: #{index} — {url}",
"zh": "PR 已存在: #{index} — {url}"
},
"Pre-push check passed: task {task_id} exists.": {
"bg": "Pre-push проверката премина: задача {task_id} съществува.",
"de": "Pre-push-Prüfung bestanden: Task {task_id} existiert.",
"en": "Pre-push check passed: task {task_id} exists.",
"pl": "Sprawdzanie pre-push zakończone: zadanie {task_id} istnieje.",
"ru": "Pre-push проверка пройдена: задача {task_id} существует.",
"zh": "Pre-push 检查通过: 任务 {task_id} 存在。"
},
"REPO_TOKEN is not set. Required to create a PR.": {
"bg": "REPO_TOKEN не е зададен. Необходим за създаване на PR.",
"de": "REPO_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
"en": "REPO_TOKEN is not set. Required to create a PR.",
"pl": "REPO_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
"ru": "REPO_TOKEN не установлен. Требуется для создания PR.",
"zh": "REPO_TOKEN 未设置。创建 PR 所需。"
},
"Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.": {
"bg": "Името на хранилището не е зададено. Използвайте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
"de": "Repository-Name nicht gesetzt. Verwende DEVX_REPO_NAME oder GITHUB_REPOSITORY env var.",
"en": "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.",
"pl": "Nazwa repozytorium nie jest ustawiona. Użyj DEVX_REPO_NAME lub GITHUB_REPOSITORY env var.",
"ru": "Имя репозитория не установлено. Используйте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
"zh": "仓库名称未设置。使用 DEVX_REPO_NAME 或 GITHUB_REPOSITORY 环境变量。"
},
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
"en": "Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.",
"pl": "Właściciel repozytorium nie jest ustawiony. Użyj --owner lub DEVX_REPO_OWNER env var.",
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
},
"VIKUNJA_TOKEN is not set. Required to derive PR title.": {
"bg": "VIKUNJA_TOKEN не е зададен. Необходим за извличане на PR заглавие.",
"de": "VIKUNJA_TOKEN nicht gesetzt. Erforderlich zum Ableiten des PR-Titels.",
"en": "VIKUNJA_TOKEN is not set. Required to derive PR title.",
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Wymagany do pobrania tytułu PR.",
"ru": "VIKUNJA_TOKEN не установлен. Требуется для получения заголовка PR.",
"zh": "VIKUNJA_TOKEN 未设置。推导 PR 标题所需。"
},
"VIKUNJA_TOKEN is not set. Set it in .env or environment.": {
"bg": "VIKUNJA_TOKEN не е зададен. Задайте го в .env или средата.",
"de": "VIKUNJA_TOKEN nicht gesetzt. In .env oder Umgebung setzen.",
"en": "VIKUNJA_TOKEN is not set. Set it in .env or environment.",
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Ustaw go w .env lub środowisku.",
"ru": "VIKUNJA_TOKEN не установлен. Установите его в .env или среде.",
"zh": "VIKUNJA_TOKEN 未设置。在 .env 或环境中设置它。"
},
"Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.": {
"bg": "Vikunja задача {task_id} не е намерена в проект {project_id}.\n Създайте я първо:\n python -m devx.tools.create_task --title \"Заглавие на задача\"\n Или проверете че ID на задачата в името на клона е правилно.",
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.\n Zuerst erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"\n Oder prüfen, ob die Task-ID im Branch-Namen korrekt ist.",
"en": "Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.",
"pl": "Zadanie Vikunja {task_id} nie znalezione w projekcie {project_id}.\n Utwórz je najpierw:\n python -m devx.tools.create_task --title \"Tytuł zadania\"\n Lub sprawdź, czy ID zadania w nazwie gałęzi jest poprawne.",
"ru": "Задача Vikunja {task_id} не найдена в проекте {project_id}.\n Сначала создайте её:\n python -m devx.tools.create_task --title \"Заголовок задачи\"\n Или проверьте, что ID задачи в имени ветки корректен.",
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。\n 请先创建:\n python -m devx.tools.create_task --title \"任务标题\"\n 或检查分支名称中的任务 ID 是否正确。"
},
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
"en": "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.",
"pl": "OSTRZEŻENIE: VIKUNJA_TOKEN nie jest ustawiony — pomijanie sprawdzania istnienia zadania. Ustaw w .env, aby włączyć pełną walidację.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
},
"[check-mutable-globals] Passed: no mutable path globals found": {
"bg": "[check-mutable-globals] Passed: no mutable path globals found",
"de": "[check-mutable-globals] Passed: no mutable path globals found",
"en": "[check-mutable-globals] Passed: no mutable path globals found",
"pl": "[check-mutable-globals] Passed: no mutable path globals found",
"ru": "[check-mutable-globals] Passed: no mutable path globals found",
"zh": "[check-mutable-globals] Passed: no mutable path globals found"
},
"[check_agent_docs] Passed: scanned {count} file(s), no stale references": {
"bg": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"de": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"en": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"pl": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"ru": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"zh": "[check_agent_docs] Passed: scanned {count} file(s), no stale references"
},
"[check_test_coverage] No changed files to check.": {
"bg": "[check_test_coverage] No changed files to check.",
"de": "[check_test_coverage] No changed files to check.",
"en": "[check_test_coverage] No changed files to check.",
"pl": "[check_test_coverage] No changed files to check.",
"ru": "[check_test_coverage] No changed files to check.",
"zh": "[check_test_coverage] No changed files to check."
},
"Additional directory to scan (default: scripts, tests). Can be repeated.": {
"bg": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"de": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"en": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"pl": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"zh": "Additional directory to scan (default: scripts, tests). Can be repeated."
},
"Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": {
"bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"en": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"pl": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"ru": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"zh": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master"
},
"Branch name (e.g., DEVX-256-fix-foo)": {
"bg": "Branch name (e.g., DEVX-256-fix-foo)",
"de": "Branch name (e.g., DEVX-256-fix-foo)",
"en": "Branch name (e.g., DEVX-256-fix-foo)",
"pl": "Branch name (e.g., DEVX-256-fix-foo)",
"ru": "Branch name (e.g., DEVX-256-fix-foo)",
"zh": "Branch name (e.g., DEVX-256-fix-foo)"
},
"Branch name must contain a task ID.": {
"bg": "Branch name must contain a task ID.",
"de": "Branch name must contain a task ID.",
"en": "Branch name must contain a task ID.",
"pl": "Branch name must contain a task ID.",
"ru": "Branch name must contain a task ID.",
"zh": "Branch name must contain a task ID."
},
"Check that changed files have corresponding tests": {
"bg": "Check that changed files have corresponding tests",
"de": "Check that changed files have corresponding tests",
"en": "Check that changed files have corresponding tests",
"pl": "Check that changed files have corresponding tests",
"ru": "Check that changed files have corresponding tests",
"zh": "Check that changed files have corresponding tests"
},
"Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).": {
"bg": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"de": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"en": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"pl": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"ru": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"zh": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found)."
},
"Dependencies must have documentation comments.": {
"bg": "Dependencies must have documentation comments.",
"de": "Dependencies must have documentation comments.",
"en": "Dependencies must have documentation comments.",
"pl": "Dependencies must have documentation comments.",
"ru": "Dependencies must have documentation comments.",
"zh": "Dependencies must have documentation comments."
},
"FAILED: {count} undocumented dependency/ies": {
"bg": "FAILED: {count} undocumented dependency/ies",
"de": "FAILED: {count} undocumented dependency/ies",
"en": "FAILED: {count} undocumented dependency/ies",
"pl": "FAILED: {count} undocumented dependency/ies",
"ru": "FAILED: {count} undocumented dependency/ies",
"zh": "FAILED: {count} undocumented dependency/ies"
},
"Found {count} mutable global(s) — use factory functions or pytest fixtures.": {
"bg": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"de": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"en": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"pl": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"ru": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"zh": "Found {count} mutable global(s) — use factory functions or pytest fixtures."
},
"Found {count} stale documentation reference(s)": {
"bg": "Found {count} stale documentation reference(s)",
"de": "Found {count} stale documentation reference(s)",
"en": "Found {count} stale documentation reference(s)",
"pl": "Found {count} stale documentation reference(s)",
"ru": "Found {count} stale documentation reference(s)",
"zh": "Found {count} stale documentation reference(s)"
},
"No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.": {
"bg": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"de": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"en": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"pl": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"ru": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"zh": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description."
},
"Only check staged files (for pre-commit)": {
"bg": "Only check staged files (for pre-commit)",
"de": "Only check staged files (for pre-commit)",
"en": "Only check staged files (for pre-commit)",
"pl": "Only check staged files (for pre-commit)",
"ru": "Only check staged files (for pre-commit)",
"zh": "Only check staged files (for pre-commit)"
},
"PR number (to fetch title from Gitea)": {
"bg": "PR number (to fetch title from Gitea)",
"de": "PR number (to fetch title from Gitea)",
"en": "PR number (to fetch title from Gitea)",
"pl": "PR number (to fetch title from Gitea)",
"ru": "PR number (to fetch title from Gitea)",
"zh": "PR number (to fetch title from Gitea)"
},
"PR title (auto-fetched if --pr-number given)": {
"bg": "PR title (auto-fetched if --pr-number given)",
"de": "PR title (auto-fetched if --pr-number given)",
"en": "PR title (auto-fetched if --pr-number given)",
"pl": "PR title (auto-fetched if --pr-number given)",
"ru": "PR title (auto-fetched if --pr-number given)",
"zh": "PR title (auto-fetched if --pr-number given)"
},
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}": {
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"pl": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}"
},
"PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}": {
"bg": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"de": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"en": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"pl": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"ru": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"zh": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}"
},
"PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}": {
"bg": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"de": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"en": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"pl": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"ru": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"zh": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}"
},
"Path to pyproject.toml (default: pyproject.toml in CWD).": {
"bg": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"de": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"en": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"pl": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"ru": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"zh": "Path to pyproject.toml (default: pyproject.toml in CWD)."
},
"Pre-merge validation failed.": {
"bg": "Pre-merge validation failed.",
"de": "Pre-merge validation failed.",
"en": "Pre-merge validation failed.",
"pl": "Pre-merge validation failed.",
"ru": "Pre-merge validation failed.",
"zh": "Pre-merge validation failed."
},
"Print warnings but always exit 0": {
"bg": "Print warnings but always exit 0",
"de": "Print warnings but always exit 0",
"en": "Print warnings but always exit 0",
"pl": "Print warnings but always exit 0",
"ru": "Print warnings but always exit 0",
"zh": "Print warnings but always exit 0"
},
"Repository in owner/name format": {
"bg": "Repository in owner/name format",
"de": "Repository in owner/name format",
"en": "Repository in owner/name format",
"pl": "Repository in owner/name format",
"ru": "Repository in owner/name format",
"zh": "Repository in owner/name format"
},
"Skip Vikunja title match check": {
"bg": "Skip Vikunja title match check",
"de": "Skip Vikunja title match check",
"en": "Skip Vikunja title match check",
"pl": "Skip Vikunja title match check",
"ru": "Skip Vikunja title match check",
"zh": "Skip Vikunja title match check"
},
"Skip branch-behind-master check": {
"bg": "Skip branch-behind-master check",
"de": "Skip branch-behind-master check",
"en": "Skip branch-behind-master check",
"pl": "Skip branch-behind-master check",
"ru": "Skip branch-behind-master check",
"zh": "Skip branch-behind-master check"
},
"[check-dep-docs] Passed: all dependencies are documented": {
"bg": "[check-dep-docs] Passed: all dependencies are documented",
"de": "[check-dep-docs] Passed: all dependencies are documented",
"en": "[check-dep-docs] Passed: all dependencies are documented",
"pl": "[check-dep-docs] Passed: all dependencies are documented",
"ru": "[check-dep-docs] Passed: all dependencies are documented",
"zh": "[check-dep-docs] Passed: all dependencies are documented"
"Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.": {
"bg": "",
"de": "",
"en": "Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.",
"pl": "",
"ru": "",
"zh": ""
}
}
}
+583
View File
@@ -0,0 +1,583 @@
"""Unit tests for devx.tools.build_image."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click import ClickException
from click.testing import CliRunner
import devx.tools.build_image as build_image
from devx.tools.build_image import (
ImageSpec,
build_full_tag,
load_manifest,
push_image,
registry_login,
)
from devx.tools.build_image import (
build_image as do_build,
)
from devx.tools.clean_images import select_for_deletion, sort_versions_by_date
class TestImageSpec:
def test_from_dict_minimal(self) -> None:
spec = ImageSpec.from_dict({"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"})
assert spec.name == "ci-base"
assert spec.dockerfile == "docker/ci-base/Dockerfile"
assert spec.context == "."
assert spec.tags == ["latest"]
def test_from_dict_full(self) -> None:
spec = ImageSpec.from_dict(
{
"name": "ci-quality",
"dockerfile": "docker/ci-quality/Dockerfile",
"context": ".",
"tags": ["latest", "0.19.3"],
}
)
assert spec.name == "ci-quality"
assert spec.dockerfile == "docker/ci-quality/Dockerfile"
assert spec.context == "."
assert spec.tags == ["latest", "0.19.3"]
def test_from_dict_missing_name(self) -> None:
with pytest.raises(ValueError, match="missing 'name'"):
ImageSpec.from_dict({"dockerfile": "Dockerfile"})
def test_from_dict_missing_dockerfile(self) -> None:
with pytest.raises(ValueError, match="missing 'dockerfile'"):
ImageSpec.from_dict({"name": "ci-base"})
def test_from_dict_tags_not_list(self) -> None:
with pytest.raises(ValueError, match="tags.*must be a list"):
ImageSpec.from_dict(
{
"name": "ci-base",
"dockerfile": "Dockerfile",
"tags": "latest",
}
)
def test_from_dict_empty_tags_defaults_to_latest(self) -> None:
spec = ImageSpec.from_dict(
{
"name": "ci-base",
"dockerfile": "Dockerfile",
"tags": [],
}
)
assert spec.tags == ["latest"]
class TestBuildFullTag:
def test_no_registry(self) -> None:
assert build_full_tag(None, "ci-base", "latest") == "ci-base:latest"
def test_with_registry(self) -> None:
assert build_full_tag("git.example.com", "ci-base", "0.1.0") == "git.example.com/ci-base:0.1.0"
def test_with_registry_and_path(self) -> None:
assert (
build_full_tag("git.example.com", "oblachno/ci-base", "latest") == "git.example.com/oblachno/ci-base:latest"
)
class TestLoadManifest:
def test_load_valid_manifest(self, tmp_path: Path) -> None:
manifest = tmp_path / "images.json"
manifest.write_text(
json.dumps(
[
{"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"},
{"name": "ci-quality", "dockerfile": "docker/ci-quality/Dockerfile", "tags": ["latest", "1.0"]},
]
)
)
specs = load_manifest(manifest)
assert len(specs) == 2
assert specs[0].name == "ci-base"
assert specs[1].tags == ["latest", "1.0"]
def test_load_missing_file(self, tmp_path: Path) -> None:
with pytest.raises(ClickException, match="not found"):
load_manifest(tmp_path / "nonexistent.json")
def test_load_not_a_list(self, tmp_path: Path) -> None:
manifest = tmp_path / "images.json"
manifest.write_text(json.dumps({"name": "ci-base"}))
with pytest.raises(ClickException, match="must be a JSON list"):
load_manifest(manifest)
class TestRegistryLogin:
def test_success(self) -> None:
mock_result = MagicMock(returncode=0, stderr="", stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert registry_login("git.example.com", "user", "token") is True
assert mock_run.call_args.args[0] == [
"docker",
"login",
"git.example.com",
"-u",
"user",
"--password-stdin",
]
assert mock_run.call_args.kwargs["input"] == "token"
def test_failure(self) -> None:
mock_result = MagicMock(returncode=1, stderr="auth failed", stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
assert registry_login("git.example.com", "user", "bad") is False
def test_dry_run(self) -> None:
with patch("devx.tools.build_image.subprocess.run") as mock_run:
assert registry_login("git.example.com", "user", "token", dry_run=True) is True
mock_run.assert_not_called()
class TestBuildImage:
def test_success(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), context=".", tags=["latest"])
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
assert do_build(spec) is True
def test_dockerfile_not_found(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="nonexistent/Dockerfile")
assert do_build(spec) is False
def test_build_failure(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile))
mock_result = MagicMock(returncode=1)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
assert do_build(spec) is False
def test_dry_run(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest", "1.0"])
with patch("devx.tools.build_image.subprocess.run") as mock_run:
assert do_build(spec, dry_run=True) is True
mock_run.assert_not_called()
def test_with_registry(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest"])
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert do_build(spec, registry="git.example.com") is True
cmd = mock_run.call_args.args[0]
assert "-t" in cmd
idx = cmd.index("-t")
assert cmd[idx + 1] == "git.example.com/ci-base:latest"
def test_pull_flag(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile))
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert do_build(spec, pull=True) is True
cmd = mock_run.call_args.args[0]
assert "--pull" in cmd
class TestPushImage:
def test_success(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
mock_result = MagicMock(returncode=0, stderr="", stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert push_image(spec, "git.example.com") is True
assert mock_run.call_count == 2
def test_partial_failure(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
results = [
MagicMock(returncode=0, stderr="", stdout=""),
MagicMock(returncode=1, stderr="push failed", stdout=""),
]
with patch("devx.tools.build_image.subprocess.run", side_effect=results):
assert push_image(spec, "git.example.com") is False
def test_dry_run(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
with patch("devx.tools.build_image.subprocess.run") as mock_run:
assert push_image(spec, "git.example.com", dry_run=True) is True
mock_run.assert_not_called()
class TestSortVersions:
def test_sort_by_created_at_desc(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01T00:00:00Z"},
{"version": "0.3.0", "created_at": "2025-03-01T00:00:00Z"},
{"version": "0.2.0", "created_at": "2025-02-01T00:00:00Z"},
]
result = sort_versions_by_date(versions)
assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"]
def test_sort_fallback_to_version(self) -> None:
versions = [
{"version": "0.1.0"},
{"version": "0.3.0"},
{"version": "0.2.0"},
]
result = sort_versions_by_date(versions)
assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"]
class TestSelectForDeletion:
def test_keep_2(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
{"version": "0.4.0", "created_at": "2025-04-01"},
]
to_delete = select_for_deletion(versions, keep=2)
assert len(to_delete) == 2
assert {v["version"] for v in to_delete} == {"0.1.0", "0.2.0"}
def test_preserve_latest_tag(self) -> None:
versions = [
{"version": "latest", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
{"version": "0.4.0", "created_at": "2025-04-01"},
]
to_delete = select_for_deletion(versions, keep=2)
deleted_versions = {v["version"] for v in to_delete}
assert "latest" not in deleted_versions
# latest is oldest by date but still preserved
assert "0.2.0" in deleted_versions
def test_keep_all(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
]
to_delete = select_for_deletion(versions, keep=2)
assert len(to_delete) == 0
def test_keep_more_than_available(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
]
to_delete = select_for_deletion(versions, keep=5)
assert len(to_delete) == 0
class TestCleanImagesAPI:
"""Tests for the clean_images module's API functions."""
def test_list_package_versions(self) -> None:
from devx.tools.clean_images import list_package_versions
mock_resp = MagicMock()
mock_resp.json.return_value = [{"version": "0.1.0"}]
mock_resp.raise_for_status = MagicMock()
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp) as mock_get:
versions = list_package_versions(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"token",
)
assert versions == [{"version": "0.1.0"}]
assert "page=1" in mock_get.call_args.args[0]
def test_list_package_versions_pagination(self) -> None:
from devx.tools.clean_images import list_package_versions
# First page: 50 items, second page: 3 items, third page: empty
page1 = [{"version": f"0.{i}.0"} for i in range(50)]
page2 = [{"version": f"1.{i}.0"} for i in range(3)]
responses = [
MagicMock(json=MagicMock(return_value=page1), raise_for_status=MagicMock()),
MagicMock(json=MagicMock(return_value=page2), raise_for_status=MagicMock()),
MagicMock(json=MagicMock(return_value=[]), raise_for_status=MagicMock()),
]
with patch("devx.tools.clean_images.requests.get", side_effect=responses):
versions = list_package_versions(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"token",
)
assert len(versions) == 53
def test_delete_package_version_success(self) -> None:
from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=204)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
)
is True
)
def test_delete_package_version_failure(self) -> None:
from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=404)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
)
is False
)
class TestCLIBuildImage:
def test_single_image_build(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--tag", "latest"],
)
assert result.exit_code == 0
def test_manifest_build(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
manifest = tmp_path / "images.json"
manifest.write_text(
json.dumps(
[
{"name": "ci-base", "dockerfile": str(dockerfile)},
]
)
)
runner = CliRunner()
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = runner.invoke(
build_image.main,
["--manifest", str(manifest)],
)
assert result.exit_code == 0
def test_missing_dockerfile_and_manifest(self) -> None:
runner = CliRunner()
result = runner.invoke(build_image.main, [])
assert result.exit_code != 0
assert "manifest" in result.output.lower() or "dockerfile" in result.output.lower()
def test_push_without_registry(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push"],
)
assert result.exit_code != 0
assert "registry" in result.output.lower()
def test_push_without_credentials(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
)
assert result.exit_code != 0
assert "credential" in result.output.lower() or "token" in result.output.lower()
def test_dry_run(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
with patch("devx.tools.build_image.subprocess.run") as mock_run:
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--dry-run"],
)
assert result.exit_code == 0
mock_run.assert_not_called()
assert "dry-run" in result.output
def test_build_failure_exits_with_error(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
mock_result = MagicMock(returncode=1)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base"],
)
assert result.exit_code != 0
def test_push_login_failure(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
login_result = MagicMock(returncode=1, stderr="auth failed", stdout="")
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "REGISTRY_USERNAME": "user"}):
with patch("devx.tools.build_image.subprocess.run", return_value=login_result):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
)
assert result.exit_code != 0
assert "login" in result.output.lower()
def test_push_image_failure(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
build_result = MagicMock(returncode=0)
login_result = MagicMock(returncode=0, stderr="", stdout="")
push_result = MagicMock(returncode=1, stderr="push failed", stdout="")
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "REGISTRY_USERNAME": "user"}):
with patch(
"devx.tools.build_image.subprocess.run",
side_effect=[login_result, build_result, push_result],
):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
)
assert result.exit_code != 0
class TestCLICleanImages:
def test_dry_run(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
mock_resp = MagicMock()
mock_resp.json.return_value = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
]
mock_resp.raise_for_status = MagicMock()
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "1", "--dry-run"],
)
assert result.exit_code == 0
assert "dry-run" in result.output
assert "0.1.0" in result.output
def test_no_token(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base"],
)
assert result.exit_code != 0
assert "token" in result.output.lower()
def test_no_versions_found(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
mock_resp = MagicMock()
mock_resp.json.return_value = []
mock_resp.raise_for_status = MagicMock()
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
)
assert result.exit_code == 0
assert "No versions" in result.output
def test_actual_delete(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
list_resp = MagicMock()
list_resp.json.return_value = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
]
list_resp.raise_for_status = MagicMock()
delete_resp = MagicMock(status_code=204)
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
)
assert result.exit_code == 0
assert "Deleted" in result.output
def test_list_request_exception(self) -> None:
import requests as req
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
with patch(
"devx.tools.clean_images.requests.get",
side_effect=req.ConnectionError("network down"),
):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
)
assert result.exit_code == 0
assert "Failed to list" in result.output
def test_delete_failure_in_cli(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
list_resp = MagicMock()
list_resp.json.return_value = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
]
list_resp.raise_for_status = MagicMock()
delete_resp = MagicMock(status_code=500)
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
)
assert result.exit_code == 0
assert "FAILED" in result.output
+17 -8
View File
@@ -89,7 +89,7 @@ class TestCheckFile:
doc.parent.mkdir(parents=True)
doc.write_text("See scripts/old.py for details.\n")
issues = _check_file(
doc, tmp_path, {"scripts/old.py"}, [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT
doc, tmp_path, {"scripts/old.py"}, [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, []
)
assert any("deleted file" in i for i in issues)
@@ -97,7 +97,7 @@ class TestCheckFile:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See scripts/nonexistent.py for details.\n")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
assert any("non-existent file" in i for i in issues)
def test_does_not_flag_existing_file(self, tmp_path: Path) -> None:
@@ -106,7 +106,7 @@ class TestCheckFile:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See scripts/exists.py for details.\n")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
assert issues == []
def test_detects_deprecated_pattern(self, tmp_path: Path) -> None:
@@ -115,7 +115,7 @@ class TestCheckFile:
doc.write_text("Use ansible/envs/prod/secrets.yml for config.\n")
patterns = [re.compile(r"ansible/envs/[^/]+/secrets\.yml")]
issues = _check_file(
doc, tmp_path, set(), patterns, [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT
doc, tmp_path, set(), patterns, [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, []
)
assert any("deprecated pattern" in i for i in issues)
@@ -129,7 +129,7 @@ class TestCheckFile:
doc.write_text("The legacy ansible/envs/prod/secrets.yml is deprecated.\n")
patterns = [re.compile(r"ansible/envs/[^/]+/secrets\.yml")]
issues = _check_file(
doc, tmp_path, set(), patterns, ["deprecated"], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT
doc, tmp_path, set(), patterns, ["deprecated"], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, []
)
assert issues == []
@@ -137,14 +137,14 @@ class TestCheckFile:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_bytes(b"\xff\xfe\x00\x00")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
assert issues == []
def test_skips_short_ref(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See a.py for details.\n")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, 5)
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, 5, [])
# "a.py" is only 4 chars, below min_path_ref_length
assert issues == []
@@ -152,10 +152,19 @@ class TestCheckFile:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See vendor/some/long/path.py for details.\n")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
# "vendor/" is not in repo_path_prefixes
assert issues == []
def test_skip_ref_prefixes_skips_nonexistent(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See scripts/test_foo.py for details.\n")
issues = _check_file(
doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, ["scripts/test_"]
)
assert issues == []
class TestCli:
def test_passes_when_no_issues(self, tmp_path: Path) -> None:
+82 -14
View File
@@ -13,6 +13,7 @@ from devx.molecule.distribute_molecule import (
PLATFORMS,
MultiRoleTestPair,
TestPair,
_load_molecule_weights,
_lpt_distribute,
_scenario_weight,
build_multi_role_pairs,
@@ -482,23 +483,90 @@ class TestCliMultiRole:
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:
def test_default_weight_no_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Without pyproject.toml, all scenarios get the default weight."""
monkeypatch.chdir(tmp_path)
scenario_w, role_w = _load_molecule_weights()
assert scenario_w == {}
assert role_w == {}
assert _scenario_weight("unknown-scenario") == 3
def test_case_insensitive(self) -> None:
assert _scenario_weight("NextCloud") == 10
assert _scenario_weight("GITEA") == 8
def test_load_weights_from_pyproject(self, tmp_path: Path) -> None:
"""Weights are loaded from [tool.devx.molecule.weights] in pyproject.toml."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
"[tool.devx.molecule.weights]\n"
'"nextcloud" = 15\n'
'"default" = 3\n'
'"binary" = 2\n'
'"app_container/customer-apps" = 11\n'
'"restore/default" = 11\n'
)
scenario_w, role_w = _load_molecule_weights(str(pyproject))
assert scenario_w == {"nextcloud": 15, "default": 3, "binary": 2}
assert role_w == {("app_container", "customer-apps"): 11, ("restore", "default"): 11}
def test_substring_match(self) -> None:
assert _scenario_weight("nextcloud-with-redis") == 10
assert _scenario_weight("custom-gitea-setup") == 8
def test_role_specific_takes_priority(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Role-specific weights take priority over scenario-name-only weights."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
'[tool.devx.molecule.weights]\n"default" = 3\n"docker_base/default" = 8\n"restore/default" = 11\n'
)
scenario_w, role_w = _load_molecule_weights(str(pyproject))
monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w)
monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w)
assert _scenario_weight("default", "docker_base") == 8
assert _scenario_weight("default", "restore") == 11
assert _scenario_weight("default", "app_container") == 3
def test_case_insensitive(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Weight keys are matched case-insensitively."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.molecule.weights]\n"nextcloud" = 15\n')
scenario_w, role_w = _load_molecule_weights(str(pyproject))
monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w)
monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w)
assert _scenario_weight("NextCloud") == 15
assert _scenario_weight("NEXTCLOUD") == 15
def test_substring_match(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Scenario-name weights use substring matching."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.molecule.weights]\n"nextcloud" = 15\n')
scenario_w, role_w = _load_molecule_weights(str(pyproject))
monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w)
monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w)
assert _scenario_weight("nextcloud-with-redis") == 15
def test_no_pyproject_returns_empty(self, tmp_path: Path) -> None:
"""Missing pyproject.toml returns empty weight dicts."""
scenario_w, role_w = _load_molecule_weights(str(tmp_path / "nonexistent.toml"))
assert scenario_w == {}
assert role_w == {}
def test_invalid_weights_ignored(self, tmp_path: Path) -> None:
"""Non-integer weight values are silently ignored."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.molecule.weights]\n"good" = 5\n"bad" = "not an int"\n')
scenario_w, role_w = _load_molecule_weights(str(pyproject))
assert scenario_w == {"good": 5}
assert role_w == {}
def test_malformed_toml_returns_empty(self, tmp_path: Path) -> None:
"""Malformed TOML returns empty weight dicts."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text("this is not valid toml = = =")
scenario_w, role_w = _load_molecule_weights(str(pyproject))
assert scenario_w == {}
assert role_w == {}
def test_non_dict_weights_returns_empty(self, tmp_path: Path) -> None:
"""If [tool.devx.molecule.weights] is not a table, returns empty dicts."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.molecule]\nweights = "not a table"\n')
scenario_w, role_w = _load_molecule_weights(str(pyproject))
assert scenario_w == {}
assert role_w == {}
class TestLptDistribute:
+44 -4
View File
@@ -1,5 +1,7 @@
"""Unit tests for scripts/ci/release.py."""
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
@@ -815,11 +817,23 @@ class TestCommitReleaseChanges:
class TestCreateAndPushTag:
@patch("devx.ci.release.tag_exists", return_value=False)
@patch("devx.ci.release.run_cmd")
def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
create_and_push_tag("0.2.0", "changelog", dry_run=False)
def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock, tmp_path: Path) -> None:
github_output = tmp_path / "output.txt"
with patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output)}):
create_and_push_tag("0.2.0", "changelog", dry_run=False)
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
assert ["git", "push", "origin", "refs/tags/v0.2.0"] in calls
assert github_output.read_text() == "tag=v0.2.0\n"
@patch("devx.ci.release.tag_exists", return_value=False)
@patch("devx.ci.release.run_cmd")
def test_no_github_output_skips_write(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
with patch.dict(os.environ, {}, clear=True):
create_and_push_tag("0.2.0", "changelog", dry_run=False)
# Should still create tag, just not write GITHUB_OUTPUT
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
@patch("devx.ci.release.tag_exists", return_value=False)
@patch("devx.ci.release.run_cmd")
@@ -1193,7 +1207,7 @@ class TestMain:
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_full_flow_tag_exists(
@@ -1218,7 +1232,33 @@ class TestMain:
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "already existed" in result.output
mock_tag.assert_called_once_with("0.1.0", "changelog", False)
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.1.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_skips_when_version_doesnt_bump(
self,
mock_run_cmd: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Release is skipped when git-cliff doesn't bump the version."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "no version bump" in result.output
assert "Skipping" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])