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. # to PyPI, so the image always has the latest released version.
# - Manually via workflow_dispatch # - 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: # The workflow builds 3 tier images in sequence:
# ci-base → ci-quality → ci-full # ci-base → ci-quality → ci-full
#
# Each tier builds FROM the previous one, so they must be built in order. # 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). # After pushing, a cleanup job removes old versions (keeps last 2 + latest).
@@ -28,9 +30,9 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
detect-type: build-and-push:
runs-on: docker runs-on: docker
timeout-minutes: 5 timeout-minutes: 30
outputs: outputs:
is-release: ${{ steps.check.outputs.is-release }} is-release: ${{ steps.check.outputs.is-release }}
steps: steps:
@@ -40,7 +42,7 @@ jobs:
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} 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 - name: Check if this is a release commit
id: check id: check
env: env:
@@ -48,25 +50,12 @@ jobs:
run: | run: |
. .venv/bin/activate . .venv/bin/activate
python3 -m devx.ci.detect_release_commit 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 - 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: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} 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 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 echo "$_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
- name: Build and push tier images - 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: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
@@ -123,7 +117,7 @@ jobs:
cleanup: cleanup:
needs: [build-and-push] 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 runs-on: docker
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
+59 -78
View File
@@ -5,20 +5,35 @@ on:
types: [opened, synchronize] types: [opened, synchronize]
workflow_dispatch: 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: 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 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
timeout-minutes: 10 timeout-minutes: 15
defaults: defaults:
run: run:
shell: bash shell: bash
outputs:
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
# --- quality steps ---
- name: Lint all - name: Lint all
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -29,14 +44,11 @@ jobs:
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
make pytest-cov make pytest-cov
- name: Check unit test speed - name: Check unit test speed
env:
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5 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) - name: Documentation gate (coverage + stale refs + lint + version refs + prose)
env: env:
PYTHONPATH: src
DEVX_DOC_COVERAGE_STRICT: "1" DEVX_DOC_COVERAGE_STRICT: "1"
DEVX_VALE_LEVEL: warning DEVX_VALE_LEVEL: warning
run: | run: |
@@ -44,8 +56,6 @@ jobs:
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
make devx-docs-check make devx-docs-check
- name: Translation completeness check - name: Translation completeness check
env:
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations python3 -m devx.ci.check_translations
@@ -66,97 +76,69 @@ jobs:
else else
echo "act_runner not found — skipping workflow dry-run (static lint still passed)" echo "act_runner not found — skipping workflow dry-run (static lint still passed)"
fi fi
# --- detect-changes step ---
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
- name: Detect changed paths - name: Detect changed paths
id: detect id: detect
env:
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.classify_changes \ python3 -m devx.ci.classify_changes \
--base "origin/master" \ --base "origin/master" \
--head "${{ github.event.pull_request.head.sha || github.sha }}" \ --head "${{ github.event.pull_request.head.sha || github.sha }}" \
--github-output --github-output
# --- validate-pr + pr-review steps (PR only) ---
release-dry-run: - name: Validate auto-merge preconditions
needs: [quality, detect-changes] if: github.event_name == 'pull_request'
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
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
run: make setup-image 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 - name: Release dry-run validation
env: if: steps.detect.outputs.user-facing-changed == 'true'
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run python3 -m devx.ci.release --dry-run
- name: Notify on failure
pr-review: if: failure()
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
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} 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: | run: |
set -euo pipefail
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \ export PATH="$HOME/.local/bin:$PATH"
"${{ github.event.number }}" \ python3 -m devx.ci.notify_failure \
"${{ github.repository }}" --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "ci/validate" \
--commit "${{ github.sha }}" \
--auto-login
auto-merge: 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. # 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: [validate]
needs: [quality, detect-changes, pr-review, release-dry-run]
if: >- if: >-
always() && always() &&
github.event_name == 'pull_request' && github.event_name == 'pull_request' &&
needs.quality.result == 'success' && needs.validate.result == 'success'
needs.pr-review.result == 'success' &&
(needs.release-dry-run.result == 'success' || needs.release-dry-run.result == 'skipped')
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
@@ -175,9 +157,9 @@ jobs:
- name: Post approval review - name: Post approval review
env: env:
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }} REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PR_NUMBER: ${{ github.event.number }} PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }} REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \ python3 -m devx.ci.pr_review \
@@ -186,13 +168,12 @@ jobs:
--event APPROVE \ --event APPROVE \
--checklist-confirmed \ --checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \ --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 - name: Squash merge with task ID
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8" DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src
HEAD_REF: ${{ github.head_ref }} HEAD_REF: ${{ github.head_ref }}
PR_TITLE: ${{ github.event.pull_request.title }} PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }} REPOSITORY: ${{ github.repository }}
+108 -261
View File
@@ -1,39 +1,39 @@
name: Post-merge name: Post-merge
# Runs on every push to master. A single workflow with conditional jobs # Runs on every push to master (after CI workflow merges a PR).
# for release, publish, wiki sync, badges, and Vikunja task updates. # 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) # The badges step always runs (even on release commits) so version
# ├── release (skip if release commit) # badge picks up the new __version__. It runs last so it sees the
# │ └── publish (needs release — builds & publishes to PyPI) # new version if release created one.
# ├── 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.
# #
# When release creates a "release: vX.Y.Z" commit and tag, the publish # 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 # step builds and publishes the package to the Gitea PyPI registry.
# Gitea PyPI registry. The release commit's post-merge run still updates # The release commit's post-merge run still updates badges. Other
# badges (version badge picks up the new version). Other jobs skip. # steps (sync-wiki, vikunja) skip on release commits.
on: on:
push: push:
branches: [master] 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: jobs:
detect-type: detect-and-configure:
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
@@ -42,184 +42,67 @@ jobs:
shell: bash shell: bash
outputs: outputs:
is-release: ${{ steps.check.outputs.is-release }} is-release: ${{ steps.check.outputs.is-release }}
is-automated: ${{ steps.check.outputs.is-automated }}
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 0
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image 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 - name: Check if this is a release commit
id: check id: check
env:
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.detect_release_commit 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 - name: Validate latest commit message
env: if: steps.check.outputs.is-automated == 'false'
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
git log -1 --format=%B > commit-msg.txt git log -1 --format=%B > commit-msg.txt
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
rm -f commit-msg.txt 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: release-and-maintain:
needs: [detect-type] needs: [detect-and-configure]
if: needs.detect-type.outputs.is-release == 'false' if: always() && needs.detect-and-configure.result == 'success'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15 timeout-minutes: 15
defaults:
run:
shell: bash
outputs: outputs:
tag: ${{ steps.release-tag.outputs.tag }} 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: defaults:
run: run:
shell: bash shell: bash
@@ -229,108 +112,72 @@ jobs:
fetch-depth: 0 fetch-depth: 0
ref: master ref: master
token: ${{ secrets.CI_GITEA_API_TOKEN }} token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Fetch latest master
run: |
git fetch origin master
git reset --hard origin/master
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} 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 - name: Generate and push badges
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PRE_COMMIT_ALLOW_NO_CONFIG: "1" PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
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 python3 -m devx.ci.push_badges
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} 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: | run: |
. .venv/bin/activate 2>/dev/null || true . .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" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \ python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "post-merge/vikunja" \ --workflow "post-merge/release-and-maintain" \
--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" \
--commit "${{ github.sha }}" \ --commit "${{ github.sha }}" \
--auto-login --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`. 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 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). CI also runs a best-effort `make workflow-dryrun` step (skipped if act_runner is not installed in the CI Docker image).
## Architecture ## Architecture
@@ -148,13 +148,13 @@ Every change to master goes through this workflow. No exceptions.
### Branch Protection (Required Gitea Settings) ### Branch Protection (Required Gitea Settings)
Branch protection and labels are automatically configured by Branch protection and labels are automatically configured by
`python -m devx.tools.configure_repo`, which runs as a `configure-repo` job in `python -m devx.tools.configure_repo`, which runs as a step in the
the post-merge workflow on every push to master. `detect-and-configure` job in the post-merge workflow on every push to master.
The following rules are enforced for `master`: The following rules are enforced for `master`:
- **Require pull request**: No direct pushes to master - **Require pull request**: No direct pushes to master
- **Require approval review**: At least 1 `APPROVE` review before merge - **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 - **Block force pushes**: No history rewriting on master
### 1. Create Vikunja Task ### 1. Create Vikunja Task
@@ -192,8 +192,9 @@ docs: update README
### 6. Review the PR ### 6. Review the PR
**Automated review (CI `pr-review` job):** Every PR triggers an automated **Automated review (CI `validate` job):** Every PR triggers an automated
review via `python -m devx.ci.pr_review`. This job posts a review with 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): `COMMENT` (no issues) or `REQUEST_CHANGES` (issues found):
- Architecture compliance (no subprocess in CLI, no hardcoded URLs) - 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: 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 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 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>` 4. Squash-merge with title: `DEVX-N: <conventional commit message>`
5. The post-merge workflow marks the Vikunja task as done 5. The post-merge workflow marks the Vikunja task as done
6. The release workflow automatically versions, tags, and publishes 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 ### Automated Release Pipeline
After a PR is merged to master, the **post-merge workflow** 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 1. **detect-and-configure** — Configures repo (branch protection, labels),
release commit (`release: vX.Y.Z`). All subsequent jobs skip for detects release commit, validates commit message. Outputs `is-release`
release commits (except badges). and `is-automated` for the next job.
2. **release** — Runs `python -m devx.ci.release` which: 2. **release-and-maintain** — Runs all post-merge maintenance as
- Checks for user-facing changes via `python -m devx.ci.classify_changes` conditional steps:
- Uses **git-cliff** to calculate the next semver version from conventional commits - **release** (if not a release commit) — Runs `python -m devx.ci.release`
- Updates `__version__` in `src/devx/__init__.py` (single source of truth) which checks for user-facing changes via `classify_changes`, uses
- Updates `CHANGELOG.md` with the new version section git-cliff for semver, updates `__version__`, updates `CHANGELOG.md`,
- Runs `make lint-ruff` and `make pytest-cov` to verify the release is healthy runs lint+tests, commits with `release: vX.Y.Z [skip ci]`, creates
- Commits with `release: vX.Y.Z [skip ci]` prefix annotated tag, pushes to master.
- Creates an annotated tag `vX.Y.Z` on the release commit - **publish** (if release created a tag) — Builds and publishes the
- Pushes both the commit and tag to master package to the Gitea PyPI registry. Checks out the release tag
within the same job.
3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL - **sync-wiki** (if not automated) — Syncs documentation to the Gitea wiki.
non-release commits (not only when release succeeds), so docs-only - **vikunja** (if not automated) — Marks the corresponding Vikunja task as done.
changes still update the wiki. - **badges** (always) — Generates and pushes quality badge SVGs to the
`badges` branch. Fetches latest master first to pick up release commits.
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`).
### Smart CI: User-Facing vs Workflow-Only Changes ### Smart CI: User-Facing vs Workflow-Only Changes
@@ -362,12 +354,11 @@ dependency is skipped, even if the condition explicitly allows
```yaml ```yaml
auto-merge: auto-merge:
needs: [quality, detect-changes, pr-review, molecule-tests] needs: [validate, molecule-tests]
if: >- if: >-
always() && always() &&
github.event_name == 'pull_request' && github.event_name == 'pull_request' &&
needs.quality.result == 'success' && needs.validate.result == 'success' &&
needs.pr-review.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped') (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 | | 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-base-latest` | Python 3.12 + devx[ci] + tea | auto-merge, detect-and-configure |
| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake + hadolint | quality, badges | | `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 | release, publish, release-dry-run, molecule-tests, deploy jobs | | `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): **Build process** (in `build-images.yml` workflow):
1. `ci-base` builds FROM `gitea/runner-images:ubuntu-latest` 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**: **Using images in workflows**:
```yaml ```yaml
jobs: jobs:
quality: validate:
runs-on: docker 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
@@ -604,7 +595,7 @@ the user should not need to specify which profile to use.
| Profile | Purpose | | 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 | | `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) | | `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 | | `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 | | 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 | | PR ready for review | `pr-reviewer` | Foreground |
| Dependency upgrade requested | `dep-upgrader` | Background | | Dependency upgrade requested | `dep-upgrader` | Background |
| Docker image build/push needed | `docker-image-builder` | 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. 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 ## [0.40.0] - 2026-07-11
### Features ### 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) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c9f25c1348d9703783e473e54f9d624879667bbc/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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/c9f25c1348d9703783e473e54f9d624879667bbc/tests.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/c9f25c1348d9703783e473e54f9d624879667bbc/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![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/c9f25c1348d9703783e473e54f9d624879667bbc/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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/c9f25c1348d9703783e473e54f9d624879667bbc/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![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/c9f25c1348d9703783e473e54f9d624879667bbc/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/python.svg)](https://www.python.org/downloads/)
## Why devx? ## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.40.0", "devx>=0.40.1",
] ]
[tool.pip] [tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
``` ```
> **Note:** If your project requires a specific devx version, pin it in > **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.40.0"`) or use a version constraint > `dependencies` (for example, `"devx==0.40.1"`) or use a version constraint
> (for example, `"devx>=0.40.0,<0.41"`). > (for example, `"devx>=0.40.1,<0.41"`).
### Optional extras ### 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) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c9f25c1348d9703783e473e54f9d624879667bbc/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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/c9f25c1348d9703783e473e54f9d624879667bbc/tests.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/c9f25c1348d9703783e473e54f9d624879667bbc/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![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/c9f25c1348d9703783e473e54f9d624879667bbc/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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/c9f25c1348d9703783e473e54f9d624879667bbc/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![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/c9f25c1348d9703783e473e54f9d624879667bbc/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a22225afd192a03120847054053e296b653bb888/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.40.0", "devx>=0.40.1",
] ]
[tool.pip] [tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple" extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
``` ```
Pin a specific version if needed: `"devx==0.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 ### 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 Sets up master branch protection (required status checks, block on rejected
reviews, block on outdated branch) and creates standard labels. Status check reviews, block on outdated branch) and creates standard labels. Status check
contexts are read from `DEVX_STATUS_CHECKS` or default to contexts are read from `DEVX_STATUS_CHECKS` or default to
`CI / quality (pull_request)`. `CI / validate (pull_request)`.
### `generate_badges.py` ### `generate_badges.py`
@@ -456,13 +456,14 @@ Developer pushes and creates PR (title: "DEVX-N: <vikunja task title>")
CI workflow (ci.yml) triggers: CI workflow (ci.yml) triggers:
├── quality (lint, tests, coverage, test speed, doc coverage, ├── validate (single job: quality + detect-changes +
translation check, dependency scan, workflow dry-run) release-dry-run + pr-review + pre-merge validation)
├── quality steps (lint, tests, coverage, test speed, doc coverage,
├── detect-changes (classify_changes.py → user-facing or workflow-only) │ │ translation check, dependency scan, workflow dry-run)
── if user-facing → release-dry-run (release.py --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) ├── pre-merge validation (check_auto_merge_ready.py)
│ └── pr-review (pr_review.py → posts COMMENT or REQUEST_CHANGES)
└── auto-merge (auto_merge.py) └── auto-merge (auto_merge.py)
├── validate PR title format ├── 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: Post-merge workflow (post-merge.yml) triggers:
├── detect-type (detect_release_commit.py) ├── detect-and-configure (single job)
── is-release? → skip all jobs except badges ── 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-and-maintain (needs detect-and-configure)
├── release (release.py) [skip if release commit or workflow-only]
├── release (release.py) │ ├── classify_changes.py → skip if workflow-only
│ ├── classify_changes.py → skip if workflow-only │ ├── git-cliff → calculate next version
│ ├── git-cliff → calculate next version │ ├── update __version__ in __init__.py
│ ├── update __version__ in __init__.py │ ├── update CHANGELOG.md
│ ├── update CHANGELOG.md │ ├── run make lint-ruff && make pytest-cov
│ ├── run make lint-ruff && make pytest-cov │ ├── commit "release: vX.Y.Z [skip ci]"
│ ├── commit "release: vX.Y.Z [skip ci]" │ ├── create annotated tag vX.Y.Z
── create annotated tag vX.Y.Z ── push commit + tag to master
└── push commit + tag to master
│ ▼ │ publish (publish.py) [if release created a tag]
Tag push triggers publish workflow (see below) ├── build package (python -m build)
│ ├── publish to Gitea PyPI registry (twine upload)
├── sync-wiki (sync_wiki.py --strict) │ │ OR publish to standard PyPI (if PYPI_TOKEN set)
└── sync docs/ to Gitea wiki with integrity check │ OR skip publish (if --skip-build)
│ └── create Gitea release with git-cliff notes
├── badges (push_badges.py) [ALWAYS runs, even on release commits]
├── fetch latest master ├── sync-wiki (sync_wiki.py --strict) [skip if automated]
── generate_badges.py → SVG files ── sync docs/ to Gitea wiki with integrity check
│ ├── push to orphan badges branch
── update README.md + docs/index.md with cache-busting URLs ── vikunja (post_merge.py) [skip if automated]
│ ├── extract task ID from commit message
├── vikunja (post_merge.py) ├── mark Vikunja task as done
├── extract task ID from commit message │ └── post comment with merge SHA
│ ├── mark Vikunja task as done
└── post comment with merge SHA └── badges (push_badges.py) [ALWAYS runs, even on release commits]
├── fetch latest master
└── configure-repo (configure_repo.py) ├── generate_badges.py → SVG files
└── ensure branch protection and labels ├── push to orphan badges branch
└── update README.md + docs/index.md with cache-busting URLs
``` ```
### Publish flow ### Publish flow
```text ```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 ├── install build, twine, git-cliff, tea
├── configure tea login ├── configure tea login
├── checkout release tag
└── publish (publish.py) └── publish (publish.py)
├── build package (python -m build) ├── build package (python -m build)
+146 -108
View File
@@ -1,32 +1,29 @@
# CI/CD Workflow # CI/CD Workflow
devx uses Gitea Actions for CI/CD automation. Three workflows implement a devx uses Gitea Actions for CI/CD automation. Two workflows implement a
complete pipeline: pull request validation, post-merge release automation, and complete pipeline: pull request validation and post-merge release
tag-triggered publishing. automation (including publishing).
## Workflow overview ## Workflow overview
```text ```text
PR opened/synchronized ──► CI (ci.yml) PR opened/synchronized ──► CI (ci.yml)
│ ├── quality │ ├── validate (quality + detect-changes +
├── detect-changes │ release-dry-run + pr-review +
├── release-dry-run (if user-facing) │ pre-merge validation)
│ ├── pr-review
│ └── auto-merge ──► squash-merge to master │ └── auto-merge ──► squash-merge to master
│ │ │ │
▼ ▼ ▼ ▼
Push to master ──► Post-merge (post-merge.yml) Push to master ──► Post-merge (post-merge.yml)
├── detect-type ├── detect-and-configure (detect-type +
├── validate-commit-msg validate-commit-msg +
├── release ──► tag vX.Y.Z │ configure-repo)
── sync-wiki │ ── release-and-maintain
├── badges │ ├── release ──► tag vX.Y.Z
├── vikunja │ ├── publish ──► Gitea PyPI registry + Gitea release
└── configure-repo │ ├── sync-wiki
├── vikunja
└── badges (always runs)
Tag push (v*) ──► Publish (publish.yml)
└── publish ──► Gitea PyPI registry + Gitea release
``` ```
## CI workflow (`ci.yml`) ## CI workflow (`ci.yml`)
@@ -35,9 +32,15 @@ Runs on pull requests (opened and synchronize) and manual dispatch.
### Jobs ### 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 1. **Lint all** — ruff check, ruff format check, pyright, bandit, actionlint
(via `make lint-all`) (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 7. **Workflow dry-run validation**`make workflow-dryrun` via act_runner
(best-effort, skipped if act_runner is not installed) (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 Classifies changes between `origin/master` and the PR head as user-facing or
workflow-only using `python -m devx.ci.classify_changes --github-output`. workflow-only using `python -m devx.ci.classify_changes --github-output`.
Writes `user-facing-changed=true|false` to the job output for use by 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 Only runs if the detect-changes step detected user-facing changes. Runs
are detected. Runs `python -m devx.ci.release --dry-run` to validate that `python -m devx.ci.release --dry-run` to validate that the release script
the release script can calculate the next version and generate the changelog can calculate the next version and generate the changelog without making
without making changes. Non-blocking (uses `|| true`). changes. Non-blocking (uses `|| true`).
#### `pr-review` **`pr-review` step**
Runs on every pull request. Executes `python -m devx.ci.pr_review` with the 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 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 7. Test coverage — source changes must include test updates
8. Commit conventions — conventional commit format on PR commits 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` #### `auto-merge`
Depends on `quality`, `detect-changes`, and `pr-review`. The final job in the Depends on `validate`. The final job in the CI workflow. Runs
CI workflow. Runs `python -m devx.ci.auto_merge` with the branch name, PR `python -m devx.ci.auto_merge` with the branch name, PR title, repository,
title, repository, and PR number: and PR number:
1. **Read task ID** from branch name (for example, `DEVX-12-fix-foo``DEVX-12`) 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>` 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 ### Smart CI: user-facing vs workflow-only changes
Not all changes require a new release. The `detect-changes` job classifies Not all changes require a new release. The `detect-changes` step in the
changes using `python -m devx.ci.classify_changes`: `validate` job classifies changes using
`python -m devx.ci.classify_changes`:
**Workflow-only paths** (infrastructure — no release needed): **Workflow-only paths** (infrastructure — no release needed):
- `.gitea/**` — Gitea Actions workflows - `.gitea/**` — Gitea Actions workflows
@@ -137,55 +154,90 @@ Rule priority (first match wins):
## Post-merge workflow (`post-merge.yml`) ## Post-merge workflow (`post-merge.yml`)
Runs on every push to master. A single workflow with conditional jobs Runs on every push to master. Consolidated into 2 jobs (from 7) to reduce
replaces separate workflows for release, wiki sync, badges, and Vikunja task runner overhead: `detect-and-configure` (detect-type + validate-commit-msg +
updates. 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 ### Job dependency graph
```text ```text
detect-type ──┬── validate-commit-msg (skip if release commit) detect-and-configure
├── release (skip if release commit) ├── configure-repo (independent, skip if release commit)
│ │ ├── detect-type → is-release? is-automated?
│ ├── sync-wiki (needs release) └── validate-commit-msg (skip if release commit)
│ ├── badges (needs release, ALWAYS runs)
│ └── vikunja (needs release)
└── configure-repo (independent, 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 `sync-wiki` and `vikunja` run only on non-automated commits (that is, real PR
task tracker are only updated when the code is actually released. If release merges) so that the wiki and task tracker are only updated when a human
fails, they are skipped to avoid leaving the wiki or Vikunja in an change lands. They skip on release commits and automated commits.
inconsistent state.
The `badges` job uses `if: always()` with no is-release condition so it runs The `badges` step always runs (even on release commits) so badges (tests,
on every push to master, including release commits. This ensures badges coverage, version, etc.) are always current. It runs last so it picks up
(tests, coverage, version, etc.) are always current. any version bump the release step created.
When `release` creates a `release: vX.Y.Z` commit, the release commit's 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 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 ### 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]`) 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 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 `is-release=false` (and `is-automated`) to the job output. The
conditionally skip for release commits. `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 Skips for release/automated commits. Validates the latest commit message
commit message using `python -m devx.ci.validate_commit_msg --branch master`. using `python -m devx.ci.validate_commit_msg --branch master`. On master,
On master, commits must follow `{PREFIX}-N: <conventional commit>` format commits must follow `{PREFIX}-N: <conventional commit>` format (added by
(added by auto-merge). auto-merge).
#### `release` **`configure-repo` step**
Depends on `detect-type`. Skips for release commits. The core release Ensures branch protection and labels are configured using
automation job. Runs `python -m devx.ci.release`: `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 1. **Classify changes** — calls `classify_changes.py` to check for user-facing
changes. If only infrastructure files changed, exits without releasing. 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 On failure, the `notify_failure` step creates a Gitea issue via
`python -m devx.ci.notify_failure`. `python -m devx.ci.notify_failure`.
#### `sync-wiki` **`sync-wiki` step**
Depends on `detect-type` and `release`. Skips for release commits. Syncs Skips for automated commits. Syncs documentation from `docs/` to the Gitea
documentation from `docs/` to the Gitea wiki using wiki using `python -m devx.ci.sync_wiki --repo <owner/repo> --strict`:
`python -m devx.ci.sync_wiki --repo <owner/repo> --strict`:
1. Reads `docs/mapping.json` to map file paths to wiki page titles 1. Reads `docs/mapping.json` to map file paths to wiki page titles
2. Lists existing wiki pages via the Gitea API 2. Lists existing wiki pages via the Gitea API
@@ -243,15 +294,14 @@ deleted).
On failure, the `notify_failure` step creates a Gitea issue. 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 Always runs (even on release commits). Generates and pushes quality badges
every push to master, including release commits. Generates and pushes quality using `python -m devx.ci.push_badges`:
badges using `python -m devx.ci.push_badges`:
1. **Fetch latest master** — `git fetch origin master && git reset --hard 1. **Fetch latest master** — `git fetch origin master && git reset --hard
origin/master` (ensures the version badge reflects the current state, 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 2. **Generate badges** — calls `devx.tools.generate_badges` which runs
pytest-cov, doc-coverage, lint checks, and version extraction, then writes pytest-cov, doc-coverage, lint checks, and version extraction, then writes
SVG files: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`, 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. On failure, the `notify_failure` step creates a Gitea issue.
#### `vikunja` **`vikunja` step**
Depends on `detect-type` and `release`. Skips for release commits. Updates Skips for automated commits. Updates the Vikunja task after a merge using
the Vikunja task after a merge using `python -m devx.ci.post_merge --git-sha `python -m devx.ci.post_merge --git-sha <sha>`:
<sha>`:
1. Extracts the task ID from the first line of the commit message 1. Extracts the task ID from the first line of the commit message
2. Marks the corresponding Vikunja task as done 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. On failure, the `notify_failure` step creates a Gitea issue.
#### `configure-repo` **`publish` step**
Depends on `detect-type`. Skips for release commits. Ensures branch Only runs if the `release` step created a tag. Builds and publishes the
protection and labels are configured using package within the same `release-and-maintain` job (checks out the release
`python -m devx.tools.configure_repo --repo <name> --owner <owner>`: tag). Runs `python -m devx.ci.publish <tag> <owner/repo>`:
- 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`
1. **Install dependencies** — build, twine, requests, python-dotenv, click, 1. **Install dependencies** — build, twine, requests, python-dotenv, click,
and the project itself 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 1. **PR merged**`auto-merge` squash-merges the PR to master with
`{PREFIX}-N <conventional commit>` title `{PREFIX}-N <conventional commit>` title
2. **Post-merge triggers** — the merge push triggers `post-merge.yml` 2. **Post-merge triggers** — the merge push triggers `post-merge.yml`
3. **detect-type** — confirms the commit is not a release commit 3. **detect-and-configure** — detects release commit, validates commit
4. **release**`release.py` calculates the next version, updates files, message, and ensures branch protection/labels
runs tests, commits `release: vX.Y.Z [skip ci]`, creates tag `vX.Y.Z`, 4. **release** (step in `release-and-maintain`) — `release.py` calculates
and pushes to master the next version, updates files, runs tests, commits
5. **Tag push triggers publish** — the tag push triggers `publish.yml` `release: vX.Y.Z [skip ci]`, creates tag `vX.Y.Z`, and pushes to master
6. **publish**`publish.py` builds the package, publishes to the Gitea PyPI 5. **publish** (step in `release-and-maintain`) `publish.py` builds the
registry, and creates a Gitea release with git-cliff notes package, publishes to the Gitea PyPI registry, and creates a Gitea
7. **sync-wiki** — documentation is synced to the Gitea wiki release with git-cliff notes (checks out the release tag within the
8. **badges** — quality badges are regenerated and pushed to the `badges` same job)
branch; README and docs/index.md are updated with cache-busting URLs 6. **sync-wiki** (step in `release-and-maintain`) — documentation is synced
9. **vikunja** — the corresponding Vikunja task is marked as done to the Gitea wiki
10. **configure-repo** — branch protection and labels are ensured 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. picks up the new version number). This prevents infinite loops.
## Failure handling ## 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, 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 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 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 ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.40.0", "devx>=0.40.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ 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.""" """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 from __future__ import annotations
import os
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@@ -548,8 +549,14 @@ def _post_manual_review(
checklist_confirmed: bool, checklist_confirmed: bool,
checklist_categories: str | None, checklist_categories: str | None,
dry_run: bool, dry_run: bool,
owner: str | None = None,
repo_name: str | None = 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: if not body or len(body) < 50:
raise click.ClickException(_("Review body must be at least 50 characters.")) 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) review = client.create_review(pr_number, event=event, body=body)
except APIError as e: except APIError as e:
if "approve" in e.message.lower() or "422" in str(e.status): if "approve" in e.message.lower() or "422" in str(e.status):
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead.")) # Self-approval not allowed (reviewer token belongs to PR author).
review = client.create_review(pr_number, event="COMMENT", body=body) # 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: else:
raise raise
review_id = review.get("id", "?") review_id = review.get("id", "?")
@@ -645,7 +664,17 @@ def main(
client = GiteaClient(GITEA_API_URL, token, owner, repo_name) client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
if event is not None: 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 return
result = run_review(client, pr_number) result = run_review(client, pr_number)
+20 -4
View File
@@ -2104,12 +2104,28 @@
"zh": "No workflow runs found for SHA {sha}." "zh": "No workflow runs found for SHA {sha}."
}, },
"Note: Self-approval not allowed. Posting COMMENT instead.": { "Note: Self-approval not allowed. Posting COMMENT instead.": {
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.", "bg": "Забележка: Само-одобрението не е разрешено. Публикуване на COMMENT вместо това.",
"de": "Note: Self-approval not allowed. Posting COMMENT instead.", "de": "Hinweis: Selbstgenehmigung nicht erlaubt. COMMENT wird stattdessen gesendet.",
"en": "Note: Self-approval not allowed. Posting COMMENT instead.", "en": "Note: Self-approval not allowed. Posting COMMENT instead.",
"pl": "Uwaga: Samo-zatwierdzenie niedozwolone. Publikowanie COMMENT zamiast tego.", "pl": "Uwaga: Samo-zatwierdzenie niedozwolone. Publikowanie COMMENT zamiast tego.",
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.", "ru": "Примечание: Самоодобрение не разрешено. Публикация COMMENT вместо этого.",
"zh": "Note: Self-approval not allowed. Posting COMMENT instead." "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.": { "Nothing to push.": {
"bg": "Nothing to push.", "bg": "Nothing to push.",
+75 -2
View File
@@ -2,6 +2,7 @@
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner from click.testing import CliRunner
from devx.ci.pr_review import ( from devx.ci.pr_review import (
@@ -902,7 +903,12 @@ class TestManualReview:
mock_client_class.return_value.create_review.assert_not_called() mock_client_class.return_value.create_review.assert_not_called()
@patch("devx.ci.pr_review.GiteaClient") @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 = mock_client_class.return_value
client.create_review.side_effect = [ client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"), APIError(422, "approve your own pull is not allowed"),
@@ -922,10 +928,77 @@ class TestManualReview:
"--checklist-categories", "--checklist-categories",
"1,2,3,4,5,6,7,8", "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 result.exit_code == 0
assert "Review #202" in result.output 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") @patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_other_error_re_raises(self, mock_client_class: MagicMock) -> None: 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: class TestInstallPythonDeps:
@patch("devx.tools.setup.subprocess.run") @patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_dev(self, mock_run: MagicMock) -> None: def test_install_dev(self, mock_run: MagicMock) -> None:
os.environ.pop("PIP_BREAK_SYSTEM_PACKAGES", None)
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "dev") _install_python_deps(".venv/bin", "dev")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"], check=False) mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"], check=False)
@patch("devx.tools.setup.subprocess.run") @patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_ci(self, mock_run: MagicMock) -> None: def test_install_ci(self, mock_run: MagicMock) -> None:
os.environ.pop("PIP_BREAK_SYSTEM_PACKAGES", None)
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "ci") _install_python_deps(".venv/bin", "ci")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"], check=False) mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"], check=False)
@patch("devx.tools.setup.subprocess.run") @patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_custom_extras(self, mock_run: MagicMock) -> None: 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) mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "ci,lint") _install_python_deps(".venv/bin", "ci,lint")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"], check=False) 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("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_failure_without_break_system(self, mock_run: MagicMock) -> None: 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) mock_run.return_value = MagicMock(returncode=1)
with pytest.raises(subprocess.CalledProcessError): with pytest.raises(subprocess.CalledProcessError):
_install_python_deps(".venv/bin", "ci") _install_python_deps(".venv/bin", "ci")