Compare commits

...
5 Commits
Author SHA1 Message Date
devx-ci-bot 59d6fa1833 release: v0.40.1 [skip ci] 2026-07-12 16:34:45 +00:00
emil d035b620e0 DEVX-127: fix: fall back to CI token when reviewer self-approval is rejected
Post-merge / detect-and-configure (push) Successful in 17s
Post-merge / release-and-maintain (push) Successful in 1m25s
2026-07-12 16:33:53 +00:00
gitea-actions-bot 5987adee64 chore: update badge URLs to commit a22225af [skip ci] 2026-07-12 01:53:50 +00:00
emil cb84dae050 DEVX-126: ci: consolidate CI and post-merge workflows
Post-merge / detect-and-configure (push) Successful in 20s
Post-merge / release-and-maintain (push) Successful in 46s
2026-07-12 01:52:40 +00:00
gitea-actions-bot ed0dfce98b chore: update badge URLs to commit 2747061d [skip ci] 2026-07-11 23:09:11 +00:00
15 changed files with 576 additions and 588 deletions
+17 -23
View File
@@ -10,9 +10,11 @@ name: Build Images
# to PyPI, so the image always has the latest released version.
# - Manually via workflow_dispatch
#
# Consolidated into 2 jobs (from 3):
# build-and-push (includes release-commit detection) ──→ cleanup
#
# 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).
@@ -28,9 +30,9 @@ concurrency:
cancel-in-progress: false
jobs:
detect-type:
build-and-push:
runs-on: docker
timeout-minutes: 5
timeout-minutes: 30
outputs:
is-release: ${{ steps.check.outputs.is-release }}
steps:
@@ -40,7 +42,7 @@ jobs:
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-ci
run: make setup-release
- name: Check if this is a release commit
id: check
env:
@@ -48,25 +50,12 @@ jobs:
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' && (
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
)
runs-on: docker
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-release
- name: Docker registry login
if: >-
steps.check.outputs.is-release == 'false' && (
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
)
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
@@ -78,6 +67,11 @@ jobs:
if [ -z "$_TOKEN" ]; then echo "Gitea API token not set — skipping Docker login"; exit 1; fi
echo "$_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
- name: Build and push tier images
if: >-
steps.check.outputs.is-release == 'false' && (
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
)
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
@@ -123,7 +117,7 @@ jobs:
cleanup:
needs: [build-and-push]
if: always() && needs.build-and-push.result == 'success'
if: always() && needs.build-and-push.result == 'success' && needs.build-and-push.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 10
steps:
+59 -78
View File
@@ -5,20 +5,35 @@ on:
types: [opened, synchronize]
workflow_dispatch:
env:
PIP_BREAK_SYSTEM_PACKAGES: "1"
PYTHONPATH: src
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs:
quality:
# Single validation job that merges: quality, detect-changes,
# release-dry-run, pr-review, and pre-merge-check.
# Uses ci-full image (has git-cliff for release-dry-run).
# Saves ~4x checkout+setup overhead vs 5 separate jobs.
validate:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15
defaults:
run:
shell: bash
outputs:
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
# --- quality steps ---
- name: Lint all
run: |
. .venv/bin/activate 2>/dev/null || true
@@ -29,14 +44,11 @@ jobs:
. .venv/bin/activate 2>/dev/null || true
make pytest-cov
- name: Check unit test speed
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
env:
PYTHONPATH: src
DEVX_DOC_COVERAGE_STRICT: "1"
DEVX_VALE_LEVEL: warning
run: |
@@ -44,8 +56,6 @@ jobs:
export PATH="$HOME/.local/bin:$PATH"
make devx-docs-check
- name: Translation completeness check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations
@@ -66,97 +76,69 @@ jobs:
else
echo "act_runner not found — skipping workflow dry-run (static lint still passed)"
fi
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:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
# --- detect-changes step ---
- name: Detect changed paths
id: detect
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.classify_changes \
--base "origin/master" \
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
--github-output
release-dry-run:
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
# --- validate-pr + pr-review steps (PR only) ---
- name: Validate auto-merge preconditions
if: github.event_name == 'pull_request'
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
HEAD_REF: ${{ github.head_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }}
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_auto_merge_ready \
--branch "$HEAD_REF" \
--pr-title "$PR_TITLE" \
--repo "$REPOSITORY" \
--pr-number "$PR_NUMBER"
- name: Run automated PR review
if: github.event_name == 'pull_request'
run: |
. .venv/bin/activate 2>/dev/null || true
set -euo pipefail
python3 -m devx.ci.pr_review \
"${{ github.event.number }}" \
"${{ github.repository }}"
# --- release-dry-run step (conditional) ---
- name: Release dry-run validation
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
if: steps.detect.outputs.user-facing-changed == 'true'
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run
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
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
- name: Run automated PR review
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
set -euo pipefail
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \
"${{ github.event.number }}" \
"${{ github.repository }}"
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "ci/validate" \
--commit "${{ github.sha }}" \
--auto-login
auto-merge:
# Auto-merge runs after all CI checks pass. It reads the task ID
# Auto-merge runs after validate passes. It reads the task ID
# from the branch name, validates the PR title, and squash-merges.
# Uses always() so it runs even when detect-changes skips (no user-facing changes).
needs: [quality, detect-changes, pr-review, release-dry-run]
needs: [validate]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
needs.pr-review.result == 'success' &&
(needs.release-dry-run.result == 'success' || needs.release-dry-run.result == 'skipped')
needs.validate.result == 'success'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
@@ -175,9 +157,9 @@ jobs:
- name: Post approval review
env:
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \
@@ -186,13 +168,12 @@ jobs:
--event APPROVE \
--checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "Auto-approved: all CI checks passed (quality, pr-review, release-dry-run)."
--body "Auto-approved: all CI checks passed (validate job)."
- name: Squash merge with task ID
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src
HEAD_REF: ${{ github.head_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
+108 -261
View File
@@ -1,39 +1,39 @@
name: Post-merge
# Runs on every push to master. A single workflow with conditional jobs
# for release, publish, wiki sync, badges, and Vikunja task updates.
# Runs on every push to master (after CI workflow merges a PR).
# Consolidated into 2 jobs (from 7) to reduce runner overhead:
# detect-and-configure ──→ release-and-maintain
#
# Job dependency graph:
# Job 1: detect release commit, validate commit msg, configure repo
# (branch protection, labels).
# Job 2: release + publish + sync-wiki + vikunja + badges.
# Individual steps are conditional on job 1 outputs.
#
# detect-type ──┬── validate-commit-msg (skip if release commit)
# ├── release (skip if release commit)
# │ └── publish (needs release — builds & publishes to PyPI)
# ├── badges (needs release — ALWAYS runs, waits for release
# │ so version badge picks up new __version__)
# ├── configure-repo (independent — skip if release commit)
# ├── sync-wiki (skip if release commit — runs for ALL merges)
# └── vikunja (skip if release commit — runs for ALL merges)
#
# sync-wiki and vikunja run for ALL non-release commits, not just when
# release succeeds. This ensures the wiki and task tracker are updated
# even for infrastructure-only changes (docs, CI config, etc.).
#
# The badges job uses `if: always()` and needs `release` so it waits for
# the release job to complete (whether it ran or was skipped). This ensures
# the version badge always reflects the latest __version__ on master.
# Badges run on every push to master, including release commits.
# The badges step always runs (even on release commits) so version
# badge picks up the new __version__. It runs last so it sees the
# new version if release created one.
#
# 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.
# step builds and publishes the package to the Gitea PyPI registry.
# The release commit's post-merge run still updates badges. Other
# steps (sync-wiki, vikunja) skip on release commits.
on:
push:
branches: [master]
concurrency:
group: post-merge-${{ github.ref }}
cancel-in-progress: true
env:
PIP_BREAK_SYSTEM_PACKAGES: "1"
PYTHONPATH: src
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs:
detect-type:
detect-and-configure:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
@@ -42,184 +42,67 @@ jobs:
shell: bash
outputs:
is-release: ${{ steps.check.outputs.is-release }}
is-automated: ${{ steps.check.outputs.is-automated }}
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
- name: Ensure branch protection and labels
env:
DEVX_REPO_NAME: devx
DEVX_REPO_OWNER: oblachno-oss
DEVX_STATUS_CHECKS: "CI / validate (pull_request)"
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.configure_repo
- name: Check if this is a release commit
id: check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.detect_release_commit
validate-commit-msg:
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
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
- name: Validate latest commit message
env:
PYTHONPATH: src
if: steps.check.outputs.is-automated == 'false'
run: |
. .venv/bin/activate 2>/dev/null || true
git log -1 --format=%B > commit-msg.txt
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
rm -f commit-msg.txt
- name: Detect changed paths
id: detect
if: steps.check.outputs.is-release == 'false'
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.classify_changes \
--base "HEAD~1" \
--head "HEAD" \
--github-output
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/detect-and-configure" \
--commit "${{ github.sha }}" \
--auto-login
release:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
release-and-maintain:
needs: [detect-and-configure]
if: always() && needs.detect-and-configure.result == 'success'
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:
fetch-depth: 0
token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
- 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:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/release" \
--commit "${{ github.sha }}" \
--auto-login
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
ref: ${{ needs.release.outputs.tag }}
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image EXTRAS=release
- name: Build and publish release
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" --auto-login
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/publish" \
--commit "${{ github.sha }}" \
--auto-login
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: 15
concurrency:
group: sync-wiki-${{ github.repository }}
cancel-in-progress: false
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
- name: Sync documentation to wiki
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --verify
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/sync-wiki" \
--commit "${{ github.sha }}" \
--auto-login
badges:
needs: [detect-type, release]
if: always()
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10
defaults:
run:
shell: bash
@@ -229,108 +112,72 @@ jobs:
fetch-depth: 0
ref: master
token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Fetch latest master
run: |
git fetch origin master
git reset --hard origin/master
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
run: make setup-image EXTRAS=release
- name: Configure git
run: |
git config user.name "devx-ci-bot"
git config user.email "devx-ci-bot@oblachno.fyi"
# --- release + publish (only if user-facing changes, not a release commit) ---
- name: Run release
id: release-tag
if: needs.detect-and-configure.outputs.is-release == 'false' && needs.detect-and-configure.outputs.user-facing-changed == 'true'
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release
- name: Build and publish release
if: steps.release-tag.outputs.tag != ''
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
git fetch --tags
git checkout "${{ steps.release-tag.outputs.tag }}"
python3 -m devx.ci.publish "${{ steps.release-tag.outputs.tag }}" "${{ github.repository }}" --auto-login
# --- sync-wiki + vikunja (skip on automated/release commits) ---
- name: Sync documentation to wiki
if: needs.detect-and-configure.outputs.is-automated == 'false'
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --verify
- name: Update Vikunja task
if: needs.detect-and-configure.outputs.is-automated == 'false'
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
# --- badges (always run — even on release commits) ---
- name: Generate and push badges
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
# Fetch latest master to pick up any release commit that was pushed
git fetch origin master
git reset --hard origin/master
python3 -m devx.ci.push_badges
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/badges" \
--commit "${{ github.sha }}" \
--auto-login
vikunja:
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
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
- name: Update Vikunja task
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/vikunja" \
--commit "${{ github.sha }}" \
--auto-login
configure-repo:
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
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
- name: Ensure branch protection and labels
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
DEVX_REPO_NAME: devx
DEVX_REPO_OWNER: oblachno-oss
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.configure_repo
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/configure-repo" \
--workflow "post-merge/release-and-maintain" \
--commit "${{ github.sha }}" \
--auto-login
+36 -45
View File
@@ -50,7 +50,7 @@ Workflow YAML files (`.gitea/workflows/*.yml`) are verified with two tools:
Both run via `make workflow-check` and are part of `make lint-all`.
The pre-commit hook runs actionlint automatically when workflow files change.
The CI `quality` job runs `make setup-quality` then `make lint-all`.
The CI `validate` job runs `make setup-image` then `make lint-all`.
CI also runs a best-effort `make workflow-dryrun` step (skipped if act_runner is not installed in the CI Docker image).
## Architecture
@@ -148,13 +148,13 @@ Every change to master goes through this workflow. No exceptions.
### Branch Protection (Required Gitea Settings)
Branch protection and labels are automatically configured by
`python -m devx.tools.configure_repo`, which runs as a `configure-repo` job in
the post-merge workflow on every push to master.
`python -m devx.tools.configure_repo`, which runs as a step in the
`detect-and-configure` job in the post-merge workflow on every push to master.
The following rules are enforced for `master`:
- **Require pull request**: No direct pushes to master
- **Require approval review**: At least 1 `APPROVE` review before merge
- **Require status checks**: CI quality must pass
- **Require status checks**: CI validate must pass
- **Block force pushes**: No history rewriting on master
### 1. Create Vikunja Task
@@ -192,8 +192,9 @@ docs: update README
### 6. Review the PR
**Automated review (CI `pr-review` job):** Every PR triggers an automated
review via `python -m devx.ci.pr_review`. This job posts a review with
**Automated review (CI `validate` job):** Every PR triggers an automated
review via `python -m devx.ci.pr_review` as a step in the `validate` job.
This posts a review with
`COMMENT` (no issues) or `REQUEST_CHANGES` (issues found):
- Architecture compliance (no subprocess in CLI, no hardcoded URLs)
@@ -216,7 +217,7 @@ Once all checklist items are verified and comments are addressed, approve
the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
1. **Validate** PR title format (`DEVX-N: <vikunja task title>`) and match against Vikunja task title
2. **Check** that at least one substantive APPROVE review exists
3. Wait for all CI checks to pass (including the `pr-review` job)
3. Wait for all CI checks to pass (including the `validate` job)
4. Squash-merge with title: `DEVX-N: <conventional commit message>`
5. The post-merge workflow marks the Vikunja task as done
6. The release workflow automatically versions, tags, and publishes
@@ -227,36 +228,27 @@ the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
### Automated Release Pipeline
After a PR is merged to master, the **post-merge workflow**
(`.gitea/workflows/post-merge.yml`) runs automatically:
(`.gitea/workflows/post-merge.yml`) runs automatically. Consolidated
into 2 jobs (from 7) to reduce runner overhead:
1. **detect-type** — Checks if the commit is a regular merge or a
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
release commits (except badges).
1. **detect-and-configure** — Configures repo (branch protection, labels),
detects release commit, validates commit message. Outputs `is-release`
and `is-automated` for the next job.
2. **release** — Runs `python -m devx.ci.release` which:
- Checks for user-facing changes via `python -m devx.ci.classify_changes`
- Uses **git-cliff** to calculate the next semver version from conventional commits
- Updates `__version__` in `src/devx/__init__.py` (single source of truth)
- Updates `CHANGELOG.md` with the new version section
- Runs `make lint-ruff` and `make pytest-cov` to verify the release is healthy
- Commits with `release: vX.Y.Z [skip ci]` prefix
- Creates an annotated tag `vX.Y.Z` on the release commit
- Pushes both the commit and tag to master
3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL
non-release commits (not only when release succeeds), so docs-only
changes still update the wiki.
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
Uses `if: always()` so it runs on every push, including release commits.
5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL
non-release commits (not only when release succeeds), so infrastructure-only
changes still update the task tracker.
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`).
2. **release-and-maintain** — Runs all post-merge maintenance as
conditional steps:
- **release** (if not a release commit) — Runs `python -m devx.ci.release`
which checks for user-facing changes via `classify_changes`, uses
git-cliff for semver, updates `__version__`, updates `CHANGELOG.md`,
runs lint+tests, commits with `release: vX.Y.Z [skip ci]`, creates
annotated tag, pushes to master.
- **publish** (if release created a tag) — Builds and publishes the
package to the Gitea PyPI registry. Checks out the release tag
within the same job.
- **sync-wiki** (if not automated) — Syncs documentation to the Gitea wiki.
- **vikunja** (if not automated) — Marks the corresponding Vikunja task as done.
- **badges** (always) — Generates and pushes quality badge SVGs to the
`badges` branch. Fetches latest master first to pick up release commits.
### Smart CI: User-Facing vs Workflow-Only Changes
@@ -362,12 +354,11 @@ dependency is skipped, even if the condition explicitly allows
```yaml
auto-merge:
needs: [quality, detect-changes, pr-review, molecule-tests]
needs: [validate, molecule-tests]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
needs.pr-review.result == 'success' &&
needs.validate.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
```
@@ -505,9 +496,9 @@ 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 + hadolint | quality, badges |
| `ci-full-latest` | ci-quality + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, release-dry-run, molecule-tests, deploy jobs |
| `ci-base-latest` | Python 3.12 + devx[ci] + tea | auto-merge, detect-and-configure |
| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake + hadolint | (badges in release-and-maintain uses ci-full) |
| `ci-full-latest` | ci-quality + devx[release,molecule,deploy] + git-cliff + OpenTofu | validate, release-and-maintain, molecule-tests, build-and-push |
**Build process** (in `build-images.yml` workflow):
1. `ci-base` builds FROM `gitea/runner-images:ubuntu-latest`
@@ -520,9 +511,9 @@ Each image is tagged `latest` and pushed to
**Using images in workflows**:
```yaml
jobs:
quality:
validate:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
steps:
- uses: actions/checkout@v4
- name: Set up environment
@@ -604,7 +595,7 @@ the user should not need to specify which profile to use.
| Profile | Purpose |
|---------|---------|
| `ci-investigator` | Investigate CI failures (quality, release, publish, wiki sync, image build) |
| `ci-investigator` | Investigate CI failures (validate, release-and-maintain, build-images) |
| `dep-upgrader` | Python dependency upgrades in pyproject.toml with dep-doc validation |
| `docker-image-builder` | Build/push/cleanup 3-tier runner images (ci-base, ci-quality, ci-full) |
| `doc-sync-specialist` | Doc coverage, doc linting, wiki sync integrity |
@@ -614,7 +605,7 @@ the user should not need to specify which profile to use.
| Trigger | Profile | Mode |
|---------|---------|------|
| CI run failure (quality, release, publish, sync-wiki, build-images) | `ci-investigator` | Background |
| CI run failure (validate, release-and-maintain, build-images) | `ci-investigator` | Background |
| PR ready for review | `pr-reviewer` | Foreground |
| Dependency upgrade requested | `dep-upgrader` | Background |
| Docker image build/push needed | `docker-image-builder` | Background |
+6
View File
@@ -2,6 +2,12 @@
All notable changes to this project will be documented in this file.
## [0.40.1] - 2026-07-12
### Bug Fixes
- Fall back to CI token when reviewer self-approval is rejected
## [0.40.0] - 2026-07-11
### Features
+9 -9
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c9f25c1348d9703783e473e54f9d624879667bbc/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c9f25c1348d9703783e473e54f9d624879667bbc/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c9f25c1348d9703783e473e54f9d624879667bbc/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c9f25c1348d9703783e473e54f9d624879667bbc/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c9f25c1348d9703783e473e54f9d624879667bbc/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c9f25c1348d9703783e473e54f9d624879667bbc/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.40.0",
"devx>=0.40.1",
]
[tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
```
> **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.40.0"`) or use a version constraint
> (for example, `"devx>=0.40.0,<0.41"`).
> `dependencies` (for example, `"devx==0.40.1"`) or use a version constraint
> (for example, `"devx>=0.40.1,<0.41"`).
### Optional extras
+8 -8
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c9f25c1348d9703783e473e54f9d624879667bbc/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c9f25c1348d9703783e473e54f9d624879667bbc/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c9f25c1348d9703783e473e54f9d624879667bbc/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c9f25c1348d9703783e473e54f9d624879667bbc/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c9f25c1348d9703783e473e54f9d624879667bbc/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c9f25c1348d9703783e473e54f9d624879667bbc/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.40.0",
"devx>=0.40.1",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.40.0"` or `"devx>=0.40.0,<0.41"`.
Pin a specific version if needed: `"devx==0.40.1"` or `"devx>=0.40.1,<0.41"`.
### Optional extras
+48 -43
View File
@@ -337,7 +337,7 @@ Configures repository branch protection and labels via the Gitea REST API.
Sets up master branch protection (required status checks, block on rejected
reviews, block on outdated branch) and creates standard labels. Status check
contexts are read from `DEVX_STATUS_CHECKS` or default to
`CI / quality (pull_request)`.
`CI / validate (pull_request)`.
### `generate_badges.py`
@@ -456,13 +456,14 @@ Developer pushes and creates PR (title: "DEVX-N: <vikunja task title>")
CI workflow (ci.yml) triggers:
├── quality (lint, tests, coverage, test speed, doc coverage,
translation check, dependency scan, workflow dry-run)
├── detect-changes (classify_changes.py → user-facing or workflow-only)
── if user-facing → release-dry-run (release.py --dry-run)
├── pr-review (pr_review.py → posts COMMENT or REQUEST_CHANGES)
├── validate (single job: quality + detect-changes +
release-dry-run + pr-review + pre-merge validation)
├── quality steps (lint, tests, coverage, test speed, doc coverage,
│ │ translation check, dependency scan, workflow dry-run)
── detect-changes (classify_changes.py → user-facing or workflow-only)
│ └── if user-facing → release-dry-run (release.py --dry-run)
├── pre-merge validation (check_auto_merge_ready.py)
│ └── pr-review (pr_review.py → posts COMMENT or REQUEST_CHANGES)
└── auto-merge (auto_merge.py)
├── validate PR title format
@@ -483,50 +484,54 @@ Push to master (squash-merge commit: "DEVX-N <conventional commit>")
Post-merge workflow (post-merge.yml) triggers:
├── detect-type (detect_release_commit.py)
── is-release? → skip all jobs except badges
├── detect-and-configure (single job)
── configure-repo (configure_repo.py)
│ ├── detect-type (detect_release_commit.py)
│ │ └── is-release? → skip all steps except badges
│ └── validate-commit-msg (validate_commit_msg.py --branch master)
── validate-commit-msg (validate_commit_msg.py --branch master)
├── release (release.py)
│ ├── classify_changes.py → skip if workflow-only
│ ├── git-cliff → calculate next version
│ ├── update __version__ in __init__.py
│ ├── update CHANGELOG.md
│ ├── run make lint-ruff && make pytest-cov
│ ├── commit "release: vX.Y.Z [skip ci]"
── create annotated tag vX.Y.Z
└── push commit + tag to master
│ ▼
Tag push triggers publish workflow (see below)
├── sync-wiki (sync_wiki.py --strict)
└── sync docs/ to Gitea wiki with integrity check
├── badges (push_badges.py) [ALWAYS runs, even on release commits]
├── fetch latest master
── generate_badges.py → SVG files
│ ├── push to orphan badges branch
── update README.md + docs/index.md with cache-busting URLs
├── vikunja (post_merge.py)
├── extract task ID from commit message
│ ├── mark Vikunja task as done
└── post comment with merge SHA
└── configure-repo (configure_repo.py)
└── ensure branch protection and labels
── release-and-maintain (needs detect-and-configure)
├── release (release.py) [skip if release commit or workflow-only]
│ ├── classify_changes.py → skip if workflow-only
│ ├── git-cliff → calculate next version
│ ├── update __version__ in __init__.py
│ ├── update CHANGELOG.md
│ ├── run make lint-ruff && make pytest-cov
│ ├── commit "release: vX.Y.Z [skip ci]"
│ ├── create annotated tag vX.Y.Z
── push commit + tag to master
│ publish (publish.py) [if release created a tag]
├── build package (python -m build)
│ ├── publish to Gitea PyPI registry (twine upload)
│ │ OR publish to standard PyPI (if PYPI_TOKEN set)
│ OR skip publish (if --skip-build)
│ └── create Gitea release with git-cliff notes
├── sync-wiki (sync_wiki.py --strict) [skip if automated]
── sync docs/ to Gitea wiki with integrity check
── vikunja (post_merge.py) [skip if automated]
│ ├── extract task ID from commit message
├── mark Vikunja task as done
│ └── post comment with merge SHA
└── badges (push_badges.py) [ALWAYS runs, even on release commits]
├── fetch latest master
├── generate_badges.py → SVG files
├── push to orphan badges branch
└── update README.md + docs/index.md with cache-busting URLs
```
### Publish flow
```text
Tag push (vX.Y.Z) triggers publish workflow (publish.yml):
Within release-and-maintain job (after release step creates a tag):
├── install build, twine, git-cliff, tea
├── configure tea login
├── checkout release tag
└── publish (publish.py)
├── build package (python -m build)
+146 -108
View File
@@ -1,32 +1,29 @@
# CI/CD Workflow
devx uses Gitea Actions for CI/CD automation. Three workflows implement a
complete pipeline: pull request validation, post-merge release automation, and
tag-triggered publishing.
devx uses Gitea Actions for CI/CD automation. Two workflows implement a
complete pipeline: pull request validation and post-merge release
automation (including publishing).
## Workflow overview
```text
PR opened/synchronized ──► CI (ci.yml)
│ ├── quality
├── detect-changes
├── release-dry-run (if user-facing)
│ ├── pr-review
│ ├── validate (quality + detect-changes +
│ release-dry-run + pr-review +
│ pre-merge validation)
│ └── auto-merge ──► squash-merge to master
│ │
▼ ▼
Push to master ──► Post-merge (post-merge.yml)
├── detect-type
├── validate-commit-msg
├── release ──► tag vX.Y.Z
── sync-wiki │
├── badges │
├── vikunja │
└── configure-repo │
Tag push (v*) ──► Publish (publish.yml)
└── publish ──► Gitea PyPI registry + Gitea release
├── detect-and-configure (detect-type +
validate-commit-msg +
│ configure-repo)
── release-and-maintain
├── release ──► tag vX.Y.Z
├── publish ──► Gitea PyPI registry + Gitea release
├── sync-wiki
├── vikunja
└── badges (always runs)
```
## CI workflow (`ci.yml`)
@@ -35,9 +32,15 @@ Runs on pull requests (opened and synchronize) and manual dispatch.
### Jobs
#### `quality`
#### `validate`
The main quality gate. Runs on every PR:
The single validation job. Consolidates the former `quality`,
`detect-changes`, `release-dry-run`, `pr-review`, and `pre-merge-check`
jobs into one job to save checkout+setup overhead. Runs on every PR.
**Quality steps**
The main quality gate:
1. **Lint all** — ruff check, ruff format check, pyright, bandit, actionlint
(via `make lint-all`)
@@ -52,21 +55,21 @@ The main quality gate. Runs on every PR:
7. **Workflow dry-run validation**`make workflow-dryrun` via act_runner
(best-effort, skipped if act_runner is not installed)
#### `detect-changes`
**`detect-changes` step**
Classifies changes between `origin/master` and the PR head as user-facing or
workflow-only using `python -m devx.ci.classify_changes --github-output`.
Writes `user-facing-changed=true|false` to the job output for use by
downstream jobs.
downstream steps.
#### `release-dry-run`
**`release-dry-run` step**
Depends on `quality` and `detect-changes`. Only runs if user-facing changes
are detected. Runs `python -m devx.ci.release --dry-run` to validate that
the release script can calculate the next version and generate the changelog
without making changes. Non-blocking (uses `|| true`).
Only runs if the detect-changes step detected user-facing changes. Runs
`python -m devx.ci.release --dry-run` to validate that the release script
can calculate the next version and generate the changelog without making
changes. Non-blocking (uses `|| true`).
#### `pr-review`
**`pr-review` step**
Runs on every pull request. Executes `python -m devx.ci.pr_review` with the
PR number and repository. Fetches the PR diff via the Gitea API and runs
@@ -87,11 +90,24 @@ Checks performed:
7. Test coverage — source changes must include test updates
8. Commit conventions — conventional commit format on PR commits
**Pre-merge validation step**
Runs on every pull request. Executes
`python -m devx.ci.check_auto_merge_ready` with the branch name, PR title,
repository, and PR number. Validates auto-merge preconditions before the
`auto-merge` job runs:
1. **Branch name** — must contain a valid task ID (for example,
`DEVX-12-fix-foo``DEVX-12`)
2. **PR title format** — must be `{PREFIX}-N: <vikunja task title>`
3. **Vikunja task** — must exist and the title must match the PR title
4. **Branch state** — must not be behind master
#### `auto-merge`
Depends on `quality`, `detect-changes`, and `pr-review`. The final job in the
CI workflow. Runs `python -m devx.ci.auto_merge` with the branch name, PR
title, repository, and PR number:
Depends on `validate`. The final job in the CI workflow. Runs
`python -m devx.ci.auto_merge` with the branch name, PR title, repository,
and PR number:
1. **Read task ID** from branch name (for example, `DEVX-12-fix-foo``DEVX-12`)
2. **Validate PR title format** — must be `{PREFIX}-N: <vikunja task title>`
@@ -107,8 +123,9 @@ The merge commit push to master triggers the post-merge workflow.
### Smart CI: user-facing vs workflow-only changes
Not all changes require a new release. The `detect-changes` job classifies
changes using `python -m devx.ci.classify_changes`:
Not all changes require a new release. The `detect-changes` step in the
`validate` job classifies changes using
`python -m devx.ci.classify_changes`:
**Workflow-only paths** (infrastructure — no release needed):
- `.gitea/**` — Gitea Actions workflows
@@ -137,55 +154,90 @@ Rule priority (first match wins):
## Post-merge workflow (`post-merge.yml`)
Runs on every push to master. A single workflow with conditional jobs
replaces separate workflows for release, wiki sync, badges, and Vikunja task
updates.
Runs on every push to master. Consolidated into 2 jobs (from 7) to reduce
runner overhead: `detect-and-configure` (detect-type + validate-commit-msg +
configure-repo) and `release-and-maintain` (release + publish + sync-wiki +
badges + vikunja). Individual steps within `release-and-maintain` are
conditional on the `detect-and-configure` job's outputs.
### Job dependency graph
```text
detect-type ──┬── validate-commit-msg (skip if release commit)
├── release (skip if release commit)
│ │
│ ├── sync-wiki (needs release)
│ ├── badges (needs release, ALWAYS runs)
│ └── vikunja (needs release)
└── configure-repo (independent, skip if release commit)
detect-and-configure
├── configure-repo (independent, skip if release commit)
├── detect-type → is-release? is-automated?
└── validate-commit-msg (skip if release commit)
release-and-maintain (needs detect-and-configure)
├── release (skip if release commit or workflow-only)
│ └── publish (if release created a tag)
├── sync-wiki (skip if automated)
├── vikunja (skip if automated)
└── badges (always runs)
```
`sync-wiki` and `vikunja` depend on `release` succeeding so that the wiki and
task tracker are only updated when the code is actually released. If release
fails, they are skipped to avoid leaving the wiki or Vikunja in an
inconsistent state.
`sync-wiki` and `vikunja` run only on non-automated commits (that is, real PR
merges) so that the wiki and task tracker are only updated when a human
change lands. They skip on release commits and automated commits.
The `badges` job uses `if: always()` with no is-release condition so it runs
on every push to master, including release commits. This ensures badges
(tests, coverage, version, etc.) are always current.
The `badges` step always runs (even on release commits) so badges (tests,
coverage, version, etc.) are always current. It runs last so it picks up
any version bump the release step created.
When `release` creates a `release: vX.Y.Z` commit, the release commit's
post-merge run still updates badges (the version badge picks up the new
version). Other jobs skip. The tag push triggers `publish.yml`.
version). Other steps skip. The `publish` step builds and publishes the
package to the Gitea PyPI registry within the same `release-and-maintain`
job (it checks out the release tag).
### Post-merge jobs
#### `detect-type`
#### `detect-and-configure`
The first post-merge job. Consolidates the former `detect-type`,
`validate-commit-msg`, and `configure-repo` jobs. Outputs `is-release`,
`is-automated`, and `user-facing-changed` for the `release-and-maintain`
job.
**`detect-type` step**
Checks if the latest commit is a release commit (`release: vX.Y.Z [skip ci]`)
using `python -m devx.ci.detect_release_commit`. Writes `is-release=true` or
`is-release=false` to the job output. All subsequent jobs use this to
conditionally skip for release commits.
`is-release=false` (and `is-automated`) to the job output. The
`release-and-maintain` job uses these to conditionally skip steps for
release commits.
#### `validate-commit-msg`
**`validate-commit-msg` step**
Depends on `detect-type`. Skips for release commits. Validates the latest
commit message using `python -m devx.ci.validate_commit_msg --branch master`.
On master, commits must follow `{PREFIX}-N: <conventional commit>` format
(added by auto-merge).
Skips for release/automated commits. Validates the latest commit message
using `python -m devx.ci.validate_commit_msg --branch master`. On master,
commits must follow `{PREFIX}-N: <conventional commit>` format (added by
auto-merge).
#### `release`
**`configure-repo` step**
Depends on `detect-type`. Skips for release commits. The core release
automation job. Runs `python -m devx.ci.release`:
Ensures branch protection and labels are configured using
`python -m devx.tools.configure_repo --repo <name> --owner <owner>`:
- Sets up master branch protection (required status checks, block on rejected
reviews, block on outdated branch)
- Creates standard labels
- Status check contexts read from `DEVX_STATUS_CHECKS` or default to
`CI / validate (pull_request)`
On failure, the `notify_failure` step creates a Gitea issue.
#### `release-and-maintain`
Depends on `detect-and-configure`. The second post-merge job. Consolidates
the former `release`, `publish`, `sync-wiki`, `badges`, and `vikunja` jobs.
Individual steps are conditional on the `detect-and-configure` job's outputs.
**`release` step**
Skips for release commits and workflow-only changes. The core release
automation step. Runs `python -m devx.ci.release`:
1. **Classify changes** — calls `classify_changes.py` to check for user-facing
changes. If only infrastructure files changed, exits without releasing.
@@ -225,11 +277,10 @@ tag/version/commit alignment.
On failure, the `notify_failure` step creates a Gitea issue via
`python -m devx.ci.notify_failure`.
#### `sync-wiki`
**`sync-wiki` step**
Depends on `detect-type` and `release`. Skips for release commits. Syncs
documentation from `docs/` to the Gitea wiki using
`python -m devx.ci.sync_wiki --repo <owner/repo> --strict`:
Skips for automated commits. Syncs documentation from `docs/` to the Gitea
wiki using `python -m devx.ci.sync_wiki --repo <owner/repo> --strict`:
1. Reads `docs/mapping.json` to map file paths to wiki page titles
2. Lists existing wiki pages via the Gitea API
@@ -243,15 +294,14 @@ deleted).
On failure, the `notify_failure` step creates a Gitea issue.
#### `badges`
**`badges` step**
Depends on `detect-type` and `release`. Uses `if: always()` so it runs on
every push to master, including release commits. Generates and pushes quality
badges using `python -m devx.ci.push_badges`:
Always runs (even on release commits). Generates and pushes quality badges
using `python -m devx.ci.push_badges`:
1. **Fetch latest master** — `git fetch origin master && git reset --hard
origin/master` (ensures the version badge reflects the current state,
even if the release job recently pushed a new version)
even if the release step recently pushed a new version)
2. **Generate badges** — calls `devx.tools.generate_badges` which runs
pytest-cov, doc-coverage, lint checks, and version extraction, then writes
SVG files: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`,
@@ -268,11 +318,10 @@ and waits 10s between attempts).
On failure, the `notify_failure` step creates a Gitea issue.
#### `vikunja`
**`vikunja` step**
Depends on `detect-type` and `release`. Skips for release commits. Updates
the Vikunja task after a merge using `python -m devx.ci.post_merge --git-sha
<sha>`:
Skips for automated commits. Updates the Vikunja task after a merge using
`python -m devx.ci.post_merge --git-sha <sha>`:
1. Extracts the task ID from the first line of the commit message
2. Marks the corresponding Vikunja task as done
@@ -280,26 +329,11 @@ the Vikunja task after a merge using `python -m devx.ci.post_merge --git-sha
On failure, the `notify_failure` step creates a Gitea issue.
#### `configure-repo`
**`publish` step**
Depends on `detect-type`. Skips for release commits. Ensures branch
protection and labels are configured using
`python -m devx.tools.configure_repo --repo <name> --owner <owner>`:
- Sets up master branch protection (required status checks, block on rejected
reviews, block on outdated branch)
- Creates standard labels
- Status check contexts read from `DEVX_STATUS_CHECKS` or default to
`CI / quality (pull_request)`
On failure, the `notify_failure` step creates a Gitea issue.
## Publish workflow (`publish.yml`)
Runs on tag pushes matching `v*`. Triggered by the `release` job in the
post-merge workflow when it creates and pushes a new version tag.
### Job: `publish`
Only runs if the `release` step created a tag. Builds and publishes the
package within the same `release-and-maintain` job (checks out the release
tag). Runs `python -m devx.ci.publish <tag> <owner/repo>`:
1. **Install dependencies** — build, twine, requests, python-dotenv, click,
and the project itself
@@ -518,25 +552,29 @@ The complete release process from PR to published package:
1. **PR merged**`auto-merge` squash-merges the PR to master with
`{PREFIX}-N <conventional commit>` title
2. **Post-merge triggers** — the merge push triggers `post-merge.yml`
3. **detect-type** — confirms the commit is not a release commit
4. **release**`release.py` calculates the next version, updates files,
runs tests, commits `release: vX.Y.Z [skip ci]`, creates tag `vX.Y.Z`,
and pushes to master
5. **Tag push triggers publish** — the tag push triggers `publish.yml`
6. **publish**`publish.py` builds the package, publishes to the Gitea PyPI
registry, and creates a Gitea release with git-cliff notes
7. **sync-wiki** — documentation is synced to the Gitea wiki
8. **badges** — quality badges are regenerated and pushed to the `badges`
branch; README and docs/index.md are updated with cache-busting URLs
9. **vikunja** — the corresponding Vikunja task is marked as done
10. **configure-repo** — branch protection and labels are ensured
3. **detect-and-configure** — detects release commit, validates commit
message, and ensures branch protection/labels
4. **release** (step in `release-and-maintain`) — `release.py` calculates
the next version, updates files, runs tests, commits
`release: vX.Y.Z [skip ci]`, creates tag `vX.Y.Z`, and pushes to master
5. **publish** (step in `release-and-maintain`) `publish.py` builds the
package, publishes to the Gitea PyPI registry, and creates a Gitea
release with git-cliff notes (checks out the release tag within the
same job)
6. **sync-wiki** (step in `release-and-maintain`) — documentation is synced
to the Gitea wiki
7. **vikunja** (step in `release-and-maintain`) — the corresponding Vikunja
task is marked as done
8. **badges** (step in `release-and-maintain`) — quality badges are
regenerated and pushed to the `badges` branch; README and docs/index.md
are updated with cache-busting URLs
The release commit's post-merge run skips all jobs except `badges` (which
The release commit's post-merge run skips all steps except `badges` (which
picks up the new version number). This prevents infinite loops.
## Failure handling
Every job in the post-merge and publish workflows has a `notify_failure` step
Every job in the CI and post-merge workflows has a `notify_failure` step
that runs `if: failure()`. This creates a Gitea issue with the workflow name,
run ID, and commit SHA, ensuring failures that would otherwise go unnoticed
in the Actions tab are surfaced as issues. The issue is created via the tea
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.40.0",
"devx>=0.40.1",
]
[project.optional-dependencies]
dev = [
"devx>=0.40.0",
"devx>=0.40.1",
]
```
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.40.0"
__version__ = "0.40.1"
+33 -4
View File
@@ -22,6 +22,7 @@ Usage:
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from typing import Any
@@ -548,8 +549,14 @@ def _post_manual_review(
checklist_confirmed: bool,
checklist_categories: str | None,
dry_run: bool,
owner: str | None = None,
repo_name: str | None = None,
) -> None:
"""Post a manual review with validation for APPROVE events."""
"""Post a manual review with validation for APPROVE events.
When self-approval is rejected (reviewer token belongs to PR author),
falls back to the CI token (different user) if available.
"""
if not body or len(body) < 50:
raise click.ClickException(_("Review body must be at least 50 characters."))
@@ -585,8 +592,20 @@ def _post_manual_review(
review = client.create_review(pr_number, event=event, body=body)
except APIError as e:
if "approve" in e.message.lower() or "422" in str(e.status):
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
review = client.create_review(pr_number, event="COMMENT", body=body)
# Self-approval not allowed (reviewer token belongs to PR author).
# Fall back to CI token (different user) if available.
ci_token = os.environ.get("CI_GITEA_API_TOKEN", "").strip()
if ci_token and owner and repo_name:
click.echo(_("Note: Self-approval not allowed with reviewer token. Retrying with CI token."))
ci_client = GiteaClient(GITEA_API_URL, ci_token, owner, repo_name)
try:
review = ci_client.create_review(pr_number, event=event, body=body)
except APIError:
click.echo(_("Note: CI token also cannot approve. Posting COMMENT instead."))
review = client.create_review(pr_number, event="COMMENT", body=body)
else:
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
review = client.create_review(pr_number, event="COMMENT", body=body)
else:
raise
review_id = review.get("id", "?")
@@ -645,7 +664,17 @@ def main(
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
if event is not None:
_post_manual_review(client, pr_number, event.upper(), body, checklist_confirmed, checklist_categories, dry_run)
_post_manual_review(
client,
pr_number,
event.upper(),
body,
checklist_confirmed,
checklist_categories,
dry_run,
owner=owner,
repo_name=repo_name,
)
return
result = run_review(client, pr_number)
+20 -4
View File
@@ -2104,12 +2104,28 @@
"zh": "No workflow runs found for SHA {sha}."
},
"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.",
"bg": "Забележка: Само-одобрението не е разрешено. Публикуване на COMMENT вместо това.",
"de": "Hinweis: Selbstgenehmigung nicht erlaubt. COMMENT wird stattdessen gesendet.",
"en": "Note: Self-approval not allowed. Posting COMMENT instead.",
"pl": "Uwaga: Samo-zatwierdzenie niedozwolone. Publikowanie COMMENT zamiast tego.",
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.",
"zh": "Note: Self-approval not allowed. Posting COMMENT instead."
"ru": "Примечание: Самоодобрение не разрешено. Публикация COMMENT вместо этого.",
"zh": "注意:不允许自我批准。改为发布 COMMENT。"
},
"Note: Self-approval not allowed with reviewer token. Retrying with CI token.": {
"bg": "Забележка: Само-одобрението не е разрешено с тоукън на рецензента. Повторен опит с CI тоукън.",
"de": "Hinweis: Selbstgenehmigung mit Reviewer-Token nicht erlaubt. Wiederholung mit CI-Token.",
"en": "Note: Self-approval not allowed with reviewer token. Retrying with CI token.",
"pl": "Uwaga: Samo-zatwierdzenie niedozwolone tokenem recenzenta. Ponawianie tokenem CI.",
"ru": "Примечание: Самоодобрение токеном ревьюера не разрешено. Повторная попытка с CI токеном.",
"zh": "注意:不允许使用审阅者令牌进行自我批准。正在使用 CI 令牌重试。"
},
"Note: CI token also cannot approve. Posting COMMENT instead.": {
"bg": "Забележка: CI тоукънът също не може да одобри. Публикуване на COMMENT вместо това.",
"de": "Hinweis: CI-Token kann ebenfalls nicht genehmigen. COMMENT wird stattdessen gesendet.",
"en": "Note: CI token also cannot approve. Posting COMMENT instead.",
"pl": "Uwaga: Token CI również nie może zatwierdzić. Publikowanie COMMENT zamiast tego.",
"ru": "Примечание: CI токен также не может одобрить. Публикация COMMENT вместо этого.",
"zh": "注意:CI 令牌也无法批准。改为发布 COMMENT。"
},
"Nothing to push.": {
"bg": "Nothing to push.",
+75 -2
View File
@@ -2,6 +2,7 @@
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.ci.pr_review import (
@@ -902,7 +903,12 @@ class TestManualReview:
mock_client_class.return_value.create_review.assert_not_called()
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_self_approval_fallback(self, mock_client_class: MagicMock) -> None:
def test_manual_review_self_approval_fallback_to_comment(
self, mock_client_class: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Self-approval with no CI token available → fall back to COMMENT."""
monkeypatch.delenv("CI_GITEA_API_TOKEN", raising=False)
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
client = mock_client_class.return_value
client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"),
@@ -922,10 +928,77 @@ class TestManualReview:
"--checklist-categories",
"1,2,3,4,5,6,7,8",
],
env={"CI_GITEA_TOKEN": "fake"},
env={"REVIEWER_GITEA_API_TOKEN": "fake-reviewer"},
)
assert result.exit_code == 0
assert "Review #202" in result.output
# Without CI_GITEA_API_TOKEN, the fallback is COMMENT
assert "Self-approval not allowed. Posting COMMENT instead." in result.output
assert client.create_review.call_count == 2
assert client.create_review.call_args_list[1].kwargs.get("event") == "COMMENT"
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_self_approval_falls_back_to_ci_token(self, mock_client_class: MagicMock) -> None:
"""Self-approval with CI token available → retry APPROVE with CI token (different user)."""
client = mock_client_class.return_value
client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"),
{"id": 303},
]
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"x" * 60,
"--checklist-confirmed",
"--checklist-categories",
"1,2,3,4,5,6,7,8",
],
env={"REVIEWER_GITEA_API_TOKEN": "fake-reviewer", "CI_GITEA_API_TOKEN": "fake-ci"},
)
assert result.exit_code == 0
assert "Review #303" in result.output
assert "Retrying with CI token" in result.output
# Second call should still be APPROVE (CI token retry)
assert client.create_review.call_count == 2
assert client.create_review.call_args_list[1].kwargs.get("event") == "APPROVE"
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_ci_token_also_fails_falls_back_to_comment(self, mock_client_class: MagicMock) -> None:
"""Self-approval + CI token retry also fails → fall back to COMMENT."""
client = mock_client_class.return_value
client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"),
APIError(422, "approve your own pull is not allowed"),
{"id": 404},
]
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"x" * 60,
"--checklist-confirmed",
"--checklist-categories",
"1,2,3,4,5,6,7,8",
],
env={"REVIEWER_GITEA_API_TOKEN": "fake-reviewer", "CI_GITEA_API_TOKEN": "fake-ci"},
)
assert result.exit_code == 0
assert "Review #404" in result.output
assert "CI token also cannot approve" in result.output
# Third call should be COMMENT (final fallback)
assert client.create_review.call_count == 3
assert client.create_review.call_args_list[2].kwargs.get("event") == "COMMENT"
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_other_error_re_raises(self, mock_client_class: MagicMock) -> None:
+8
View File
@@ -34,19 +34,25 @@ class TestRun:
class TestInstallPythonDeps:
@patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_dev(self, mock_run: MagicMock) -> None:
os.environ.pop("PIP_BREAK_SYSTEM_PACKAGES", None)
mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "dev")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"], check=False)
@patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_ci(self, mock_run: MagicMock) -> None:
os.environ.pop("PIP_BREAK_SYSTEM_PACKAGES", None)
mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "ci")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"], check=False)
@patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_custom_extras(self, mock_run: MagicMock) -> None:
os.environ.pop("PIP_BREAK_SYSTEM_PACKAGES", None)
mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "ci,lint")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"], check=False)
@@ -71,7 +77,9 @@ class TestInstallPythonDeps:
)
@patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_failure_without_break_system(self, mock_run: MagicMock) -> None:
os.environ.pop("PIP_BREAK_SYSTEM_PACKAGES", None)
mock_run.return_value = MagicMock(returncode=1)
with pytest.raises(subprocess.CalledProcessError):
_install_python_deps(".venv/bin", "ci")