diff --git a/.gitea/workflows/build-images.yml b/.gitea/workflows/build-images.yml index e88e5e2..faa0b6f 100644 --- a/.gitea/workflows/build-images.yml +++ b/.gitea/workflows/build-images.yml @@ -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: diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0222e2c..68993cd 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 @@ -177,7 +159,6 @@ jobs: REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_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 +167,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 }} diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml index 11963ae..c5952ba 100644 --- a/.gitea/workflows/post-merge.yml +++ b/.gitea/workflows/post-merge.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 4e8fa1e..88c1392 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: `) 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: ` 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 | diff --git a/docs/tech/architecture.md b/docs/tech/architecture.md index 271f5c5..905ee44 100644 --- a/docs/tech/architecture.md +++ b/docs/tech/architecture.md @@ -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: ") ▼ 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 ") ▼ 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) diff --git a/docs/tech/ci-cd-workflow.md b/docs/tech/ci-cd-workflow.md index 5a10d6b..094dbbc 100644 --- a/docs/tech/ci-cd-workflow.md +++ b/docs/tech/ci-cd-workflow.md @@ -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: ` +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: ` @@ -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: ` 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: ` 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 --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 --strict`: +Skips for automated commits. Syncs documentation from `docs/` to the Gitea +wiki using `python -m devx.ci.sync_wiki --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 -`: +Skips for automated commits. Updates the Vikunja task after a merge using +`python -m devx.ci.post_merge --git-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 --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 `: 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 ` 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 diff --git a/tests/unit/test_setup.py b/tests/unit/test_setup.py index edb340a..8816bcd 100644 --- a/tests/unit/test_setup.py +++ b/tests/unit/test_setup.py @@ -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")