Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59d6fa1833 | ||
|
|
d035b620e0 | ||
|
|
5987adee64 | ||
|
|
cb84dae050 | ||
|
|
ed0dfce98b | ||
|
|
c244881f22 | ||
|
|
4cde7de696 | ||
|
|
d675889604 | ||
|
|
e23138e731 | ||
|
|
ef3b882e5b | ||
|
|
8d9ee1ea26 | ||
|
|
1497b29487 | ||
|
|
cb126e83da |
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -88,7 +88,9 @@ src/devx/
|
|||||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
||||||
│ ├── check_translations.py # Translation completeness check
|
│ ├── check_translations.py # Translation completeness check
|
||||||
│ ├── doc_coverage.py # Documentation coverage check
|
│ ├── doc_coverage.py # Documentation coverage check
|
||||||
│ └── lint_docs.py # Documentation linter (structure, links, headings, code blocks, orphans)
|
│ ├── lint_docs.py # Documentation linter (structure, links, headings, code blocks, orphans)
|
||||||
|
│ ├── validate_deploy_ref.py # Validate git tag for deployments (--github-output)
|
||||||
|
│ └── record_deployed_tag.py # Record deployed tag to Gitea repo variable
|
||||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||||
│ ├── setup.py # Environment setup (venv, deps, hooks)
|
│ ├── setup.py # Environment setup (venv, deps, hooks)
|
||||||
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint, vale
|
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint, vale
|
||||||
@@ -113,6 +115,16 @@ src/devx/
|
|||||||
│ ├── pre_push_check.py # Validate Vikunja task existence before push
|
│ ├── pre_push_check.py # Validate Vikunja task existence before push
|
||||||
│ └── _shared.py # Shared tool utilities
|
│ └── _shared.py # Shared tool utilities
|
||||||
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||||
|
├── utils/ # Shared utilities (reusable across projects)
|
||||||
|
│ ├── api.py # API response helpers (is_truthy, is_falsy)
|
||||||
|
│ ├── ssh.py # SSH exec + wait_for_ssh (pure-Python socket check)
|
||||||
|
│ ├── crypto.py # Secret generation (shell-safe passwords)
|
||||||
|
│ ├── vault.py # Ansible vault encrypt/decrypt helpers
|
||||||
|
│ ├── network.py # HTTP connectivity check + wait_for_ssh
|
||||||
|
│ ├── confirm.py # Typed confirmation validation for destructive ops
|
||||||
|
│ ├── json_registry.py # File-locked JSON registry for local state
|
||||||
|
│ ├── step_tracker.py # Multi-step operation tracking with reports
|
||||||
|
│ └── logging.py # XDG-compliant logging configuration
|
||||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||||
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
|
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
|
||||||
@@ -136,18 +148,24 @@ 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
|
||||||
Create a task in Vikunja to get a `DEVX-N` identifier.
|
Create a task in Vikunja to get a `DEVX-N` identifier.
|
||||||
|
|
||||||
|
**IMPORTANT:** The task title must NOT include the `DEVX-N:` prefix.
|
||||||
|
The `make create-pr` and `check_auto_merge_ready` commands automatically
|
||||||
|
prepend `DEVX-N: ` to the Vikunja task title when forming the PR title.
|
||||||
|
If the Vikunja task title already includes the prefix, the PR title will
|
||||||
|
have a double prefix and auto-merge validation will fail.
|
||||||
|
|
||||||
### 2. Create Branch
|
### 2. Create Branch
|
||||||
```bash
|
```bash
|
||||||
git checkout master && git pull
|
git checkout master && git pull
|
||||||
@@ -174,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)
|
||||||
@@ -198,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
|
||||||
@@ -209,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
|
||||||
|
|
||||||
@@ -344,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')
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -487,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`
|
||||||
@@ -502,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
|
||||||
@@ -586,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 |
|
||||||
@@ -596,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 |
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Detect double-prefix in Vikunja task title during pre-merge validation
|
||||||
|
|
||||||
|
## [0.39.0] - 2026-07-09
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Extract shared utilities from infra and grm into devx
|
||||||
|
|
||||||
## [0.38.0] - 2026-07-08
|
## [0.38.0] - 2026-07-08
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](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.38.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.38.0"`) or use a version constraint
|
> `dependencies` (for example, `"devx==0.40.1"`) or use a version constraint
|
||||||
> (for example, `"devx>=0.38.0,<0.39"`).
|
> (for example, `"devx>=0.40.1,<0.41"`).
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](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.38.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.38.0"` or `"devx>=0.38.0,<0.39"`.
|
Pin a specific version if needed: `"devx==0.40.1"` or `"devx>=0.40.1,<0.41"`.
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
+48
-43
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.38.0",
|
"devx>=0.40.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"devx>=0.38.0",
|
"devx>=0.40.1",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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.38.0"
|
__version__ = "0.40.1"
|
||||||
|
|||||||
@@ -241,17 +241,33 @@ def cli(
|
|||||||
else:
|
else:
|
||||||
click.echo("[pre-merge-check] WARNING: VIKUNJA_TOKEN not set — skipping Vikunja title match check.")
|
click.echo("[pre-merge-check] WARNING: VIKUNJA_TOKEN not set — skipping Vikunja title match check.")
|
||||||
else:
|
else:
|
||||||
expected = f"{task_id}: {vikunja_title}"
|
# Defensive check: warn if the Vikunja task title already includes
|
||||||
if pr_title != expected:
|
# the task ID prefix. The expected PR title is
|
||||||
|
# f"{task_id}: {vikunja_title}" — if vikunja_title already starts
|
||||||
|
# with "{task_id}:", the PR title will have a double prefix.
|
||||||
|
if vikunja_title.startswith(f"{task_id}:"):
|
||||||
errors.append(
|
errors.append(
|
||||||
_(
|
_(
|
||||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
"Vikunja task title '{title}' starts with '{prefix}:'. "
|
||||||
expected=expected,
|
"The task title should NOT include the '{prefix}' prefix — "
|
||||||
title=pr_title,
|
"it is automatically added to the PR title. "
|
||||||
|
"Update the Vikunja task title to remove the prefix.",
|
||||||
|
title=vikunja_title,
|
||||||
|
prefix=task_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
click.echo(f"[pre-merge-check] Vikunja title match OK: {expected}")
|
expected = f"{task_id}: {vikunja_title}"
|
||||||
|
if pr_title != expected:
|
||||||
|
errors.append(
|
||||||
|
_(
|
||||||
|
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||||
|
expected=expected,
|
||||||
|
title=pr_title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
click.echo(f"[pre-merge-check] Vikunja title match OK: {expected}")
|
||||||
|
|
||||||
# 6. Branch behind master (skip if --skip-behind-check)
|
# 6. Branch behind master (skip if --skip-behind-check)
|
||||||
if not skip_behind_check:
|
if not skip_behind_check:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Record the deployed git tag for a given environment.
|
||||||
|
|
||||||
|
Writes the tag to a Gitea repository variable so it can be queried
|
||||||
|
later via the Gitea API or ``devx.ci.get_deployed_tag``.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python -m devx.ci.record_deployed_tag --env production --tag v0.28.1
|
||||||
|
python -m devx.ci.record_deployed_tag --env staging --tag master-abc1234
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.api_clients import GiteaClient
|
||||||
|
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||||
|
from devx.i18n import _
|
||||||
|
from devx.tokens import get_ci_token
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--env",
|
||||||
|
"env_name",
|
||||||
|
type=click.Choice(["staging", "production"]),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
@click.option("--tag", required=True, help=_("Git tag or ref that was deployed"))
|
||||||
|
def main(env_name: str, tag: str) -> None:
|
||||||
|
"""Record the deployed tag for the given environment."""
|
||||||
|
try:
|
||||||
|
token = get_ci_token()
|
||||||
|
except click.ClickException as exc:
|
||||||
|
click.echo(f"Error: {exc.message}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
var_name = f"{env_name.upper()}_DEPLOY_TAG"
|
||||||
|
client = GiteaClient(GITEA_API_URL, token, REPO_OWNER, REPO_NAME)
|
||||||
|
client.set_repo_variable(var_name, tag)
|
||||||
|
click.echo(f"Recorded {var_name} = {tag}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Resolve and validate the git tag to deploy.
|
||||||
|
|
||||||
|
Shared between staging and production deployments. Ensures a concrete
|
||||||
|
git tag is used — never a moving branch ref — so deployments are
|
||||||
|
reproducible and rollback-friendly.
|
||||||
|
|
||||||
|
Usage in workflows::
|
||||||
|
|
||||||
|
# Production (tag required)
|
||||||
|
python -m devx.ci.validate_deploy_ref --tag "$TAG" --github-output
|
||||||
|
|
||||||
|
# Staging force-deploy (tag required)
|
||||||
|
python -m devx.ci.validate_deploy_ref --tag "$TAG" --github-output
|
||||||
|
|
||||||
|
# Staging PR-triggered (PR SHA is already concrete, no tag needed)
|
||||||
|
python -m devx.ci.validate_deploy_ref --allow-empty --github-output
|
||||||
|
|
||||||
|
Writes ``deploy-ref=<tag>`` to ``$GITHUB_OUTPUT`` when ``--github-output``
|
||||||
|
is passed, otherwise prints the ref to stdout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess # nosec B404
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--tag", default="", help=_("Git tag to deploy (e.g. v0.28.1)."))
|
||||||
|
@click.option(
|
||||||
|
"--allow-empty",
|
||||||
|
is_flag=True,
|
||||||
|
help=_("Allow empty tag (PR mode where SHA is concrete)."),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--github-output",
|
||||||
|
is_flag=True,
|
||||||
|
help=_("Write deploy-ref to $GITHUB_OUTPUT file."),
|
||||||
|
)
|
||||||
|
def main(tag: str, allow_empty: bool, github_output: bool) -> None:
|
||||||
|
"""Resolve and validate the deploy ref, exiting non-zero on failure."""
|
||||||
|
if not tag:
|
||||||
|
if not allow_empty:
|
||||||
|
click.echo(
|
||||||
|
"::error::No tag specified. Deployments require a concrete git tag "
|
||||||
|
"(e.g. v0.28.1). Use --allow-empty only for PR-triggered staging deploys "
|
||||||
|
"where the checkout SHA is already concrete.",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
ref = ""
|
||||||
|
click.echo("No tag specified — using checkout ref (PR mode).")
|
||||||
|
else:
|
||||||
|
result = subprocess.run( # nosec B603, B607
|
||||||
|
["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
click.echo(f"::error::Tag '{tag}' does not exist in the repository.", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
ref = tag
|
||||||
|
commit = result.stdout.strip()[:8]
|
||||||
|
click.echo(f"Deploying tag: {tag} (commit {commit})")
|
||||||
|
|
||||||
|
if github_output:
|
||||||
|
github_output_path = os.environ.get("GITHUB_OUTPUT")
|
||||||
|
if not github_output_path:
|
||||||
|
click.echo("::error::GITHUB_OUTPUT environment variable not set.", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
with open(github_output_path, "a") as f:
|
||||||
|
f.write(f"deploy-ref={ref}\n")
|
||||||
|
else:
|
||||||
|
click.echo(ref)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main()
|
||||||
@@ -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.",
|
||||||
@@ -3566,5 +3582,45 @@
|
|||||||
"pl": "{separator}",
|
"pl": "{separator}",
|
||||||
"ru": "{separator}",
|
"ru": "{separator}",
|
||||||
"zh": "{separator}"
|
"zh": "{separator}"
|
||||||
|
},
|
||||||
|
"Allow empty tag (PR mode where SHA is concrete).": {
|
||||||
|
"bg": "Позволи празен таг (PR режим, където SHA е конкретен).",
|
||||||
|
"de": "Leeren Tag zulassen (PR-Modus, in dem SHA konkret ist).",
|
||||||
|
"en": "Allow empty tag (PR mode where SHA is concrete).",
|
||||||
|
"pl": "Zezwalaj na pusty tag (tryb PR, w którym SHA jest konkretne).",
|
||||||
|
"ru": "Разрешить пустой тег (режим PR, где SHA конкретен).",
|
||||||
|
"zh": "允许空标签(SHA 为具体值的 PR 模式)。"
|
||||||
|
},
|
||||||
|
"Git tag or ref that was deployed": {
|
||||||
|
"bg": "Git таг или референция, която беше разгърната",
|
||||||
|
"de": "Git-Tag oder Ref, der bereitgestellt wurde",
|
||||||
|
"en": "Git tag or ref that was deployed",
|
||||||
|
"pl": "Tag Git lub ref, który został wdrożony",
|
||||||
|
"ru": "Git-тег или ссылка, которые были развёрнуты",
|
||||||
|
"zh": "已部署的 Git 标签或引用"
|
||||||
|
},
|
||||||
|
"Git tag to deploy (e.g. v0.28.1).": {
|
||||||
|
"bg": "Git таг за разгръщане (напр. v0.28.1).",
|
||||||
|
"de": "Git-Tag für Bereitstellung (z.B. v0.28.1).",
|
||||||
|
"en": "Git tag to deploy (e.g. v0.28.1).",
|
||||||
|
"pl": "Tag Git do wdrożenia (np. v0.28.1).",
|
||||||
|
"ru": "Git-тег для развёртывания (напр. v0.28.1).",
|
||||||
|
"zh": "要部署的 Git 标签(例如 v0.28.1)。"
|
||||||
|
},
|
||||||
|
"Write deploy-ref to $GITHUB_OUTPUT file.": {
|
||||||
|
"bg": "Запиши deploy-ref в $GITHUB_OUTPUT файла.",
|
||||||
|
"de": "Deploy-ref in $GITHUB_OUTPUT-Datei schreiben.",
|
||||||
|
"en": "Write deploy-ref to $GITHUB_OUTPUT file.",
|
||||||
|
"pl": "Zapisz deploy-ref do pliku $GITHUB_OUTPUT.",
|
||||||
|
"ru": "Записать deploy-ref в файл $GITHUB_OUTPUT.",
|
||||||
|
"zh": "将 deploy-ref 写入 $GITHUB_OUTPUT 文件。"
|
||||||
|
},
|
||||||
|
"Vikunja task title '{title}' starts with '{prefix}:'. The task title should NOT include the '{prefix}' prefix — it is automatically added to the PR title. Update the Vikunja task title to remove the prefix.": {
|
||||||
|
"bg": "Заглавието на задачата във Vikunja '{title}' започва с '{prefix}:'. Заглавието на задачата НЕ трябва да съдържа префикса '{prefix}' — той се добавя автоматично към заглавието на PR. Актуализирайте заглавието на задачата във Vikunja, за да премахнете префикса.",
|
||||||
|
"de": "Der Vikunja-Aufgabentitel '{title}' beginnt mit '{prefix}:'. Der Aufgabentitel darf NICHT den Präfix '{prefix}' enthalten — er wird automatisch zum PR-Titel hinzugefügt. Aktualisieren Sie den Vikunja-Aufgabentitel, um den Präfix zu entfernen.",
|
||||||
|
"en": "Vikunja task title '{title}' starts with '{prefix}:'. The task title should NOT include the '{prefix}' prefix — it is automatically added to the PR title. Update the Vikunja task title to remove the prefix.",
|
||||||
|
"pl": "Tytuł zadania Vikunja '{title}' zaczyna się od '{prefix}:'. Tytuł zadania nie powinien zawierać prefiksu '{prefix}' — jest on automatycznie dodawany do tytułu PR. Zaktualizuj tytuł zadania Vikunja, aby usunąć prefiks.",
|
||||||
|
"ru": "Заголовок задачи Vikunja '{title}' начинается с '{prefix}:'. Заголовок задачи НЕ должен включать префикс '{prefix}' — он автоматически добавляется к заголовку PR. Обновите заголовок задачи Vikunja, чтобы удалить префикс.",
|
||||||
|
"zh": "Vikunja 任务标题 '{title}' 以 '{prefix}:' 开头。任务标题不应包含 '{prefix}' 前缀 — 它会自动添加到 PR 标题中。请更新 Vikunja 任务标题以删除前缀。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Typed confirmation validation for destructive operations.
|
||||||
|
|
||||||
|
Ensures the user typed an exact confirmation phrase before proceeding
|
||||||
|
with dangerous operations (e.g. production deploys, database migrations).
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from devx.utils.confirm import validate_confirmation
|
||||||
|
|
||||||
|
if not validate_confirmation(user_input, expected="deploy-production"):
|
||||||
|
raise SystemExit("Confirmation does not match")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def validate_confirmation(confirm: str, expected: str) -> bool:
|
||||||
|
"""Check if confirmation text matches the expected phrase.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
confirm: The confirmation text entered by the user.
|
||||||
|
expected: The exact phrase that must be matched.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if confirmation matches exactly, False otherwise.
|
||||||
|
"""
|
||||||
|
return confirm == expected
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Cryptographic secret generation helpers.
|
||||||
|
|
||||||
|
Provides safe secret/password generators that avoid shell-option
|
||||||
|
interpretation issues (e.g. leading ``-`` being parsed as a flag by
|
||||||
|
``su -c`` in Docker entrypoints).
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from devx.utils.crypto import generate_secret, generate_password
|
||||||
|
|
||||||
|
api_key = generate_secret()
|
||||||
|
db_password = generate_password(length=32)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
_SYMBOLS = "!@#$%^&*()-_=+[]{}|;:,.<>?"
|
||||||
|
_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
_LOWER = "abcdefghijklmnopqrstuvwxyz"
|
||||||
|
_DIGITS = "0123456789"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_secret() -> str:
|
||||||
|
"""Generate a URL-safe secret that never starts with ``-``.
|
||||||
|
|
||||||
|
A leading ``-`` causes passwords to be interpreted as command-line
|
||||||
|
options when passed through shell expansion chains (e.g. Nextcloud's
|
||||||
|
Docker entrypoint uses ``su -c`` which strips quoting).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A 43-character URL-safe base64 secret.
|
||||||
|
"""
|
||||||
|
value = secrets.token_urlsafe(32)
|
||||||
|
while value.startswith("-"):
|
||||||
|
value = secrets.token_urlsafe(32)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def generate_password(length: int = 32) -> str:
|
||||||
|
"""Generate a password guaranteed to contain upper, lower, digit, and symbol.
|
||||||
|
|
||||||
|
The first character is always alphanumeric to avoid being interpreted
|
||||||
|
as a command-line option when passed through shell expansion chains.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
length: Desired password length (minimum 4).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A password string with guaranteed character class coverage.
|
||||||
|
"""
|
||||||
|
pools = [_UPPER, _LOWER, _DIGITS, _SYMBOLS]
|
||||||
|
chars = [secrets.choice(p) for p in pools]
|
||||||
|
all_chars = "".join(pools)
|
||||||
|
chars += [secrets.choice(all_chars) for _ in range(length - len(pools))]
|
||||||
|
secrets.SystemRandom().shuffle(chars)
|
||||||
|
while chars[0] in _SYMBOLS:
|
||||||
|
secrets.SystemRandom().shuffle(chars)
|
||||||
|
return "".join(chars)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_hex_secret(length: int = 32) -> str:
|
||||||
|
"""Generate a hexadecimal secret of the given length.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
length: Desired number of hex characters (doubled internally
|
||||||
|
since ``token_hex`` produces pairs).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A hexadecimal string.
|
||||||
|
"""
|
||||||
|
return secrets.token_hex(length // 2)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""File-locked JSON registry for local state management.
|
||||||
|
|
||||||
|
Provides a simple JSON-backed key-value store with ``fcntl`` file
|
||||||
|
locking for safe concurrent access. Useful for CLI tools that need
|
||||||
|
to track remote resources (runners, VMs, deployments) on the local
|
||||||
|
machine.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from devx.utils.json_registry import JsonRegistry
|
||||||
|
|
||||||
|
registry = JsonRegistry(Path("~/.local/share/myapp/state.json"))
|
||||||
|
registry.add("item1", host="10.0.0.1", user="deploy")
|
||||||
|
info = registry.get("item1")
|
||||||
|
registry.remove("item1")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRegistry:
|
||||||
|
"""Manages a local JSON file mapping names to arbitrary metadata.
|
||||||
|
|
||||||
|
Uses ``fcntl`` for file locking (shared lock for reads, exclusive
|
||||||
|
lock for writes) to prevent race conditions in concurrent scenarios.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: Path | None = None) -> None:
|
||||||
|
"""Initialise the registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to the JSON file. Defaults to
|
||||||
|
``~/.local/share/devx/registry.json``.
|
||||||
|
"""
|
||||||
|
self._path = path or Path.home() / ".local" / "share" / "devx" / "registry.json"
|
||||||
|
self._data: dict[str, dict[str, Any]] = self._load()
|
||||||
|
|
||||||
|
def _load(self) -> dict[str, dict[str, Any]]:
|
||||||
|
if not self._path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(self._path) as f:
|
||||||
|
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||||||
|
try:
|
||||||
|
data: Any = json.load(f)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return cast(dict[str, dict[str, Any]], data)
|
||||||
|
finally:
|
||||||
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(self._path, "w") as f:
|
||||||
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
json.dump(self._data, f, indent=2)
|
||||||
|
finally:
|
||||||
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
def add(self, name: str, **fields: Any) -> None:
|
||||||
|
"""Register or overwrite an entry in the registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Unique key for the entry.
|
||||||
|
**fields: Arbitrary metadata fields to store.
|
||||||
|
"""
|
||||||
|
self._data[name] = {
|
||||||
|
**fields,
|
||||||
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def get(self, name: str) -> dict[str, Any] | None:
|
||||||
|
"""Retrieve entry metadata by name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Key to look up.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A copy of the entry's metadata, or None if not found.
|
||||||
|
"""
|
||||||
|
info = self._data.get(name)
|
||||||
|
if info:
|
||||||
|
return copy.deepcopy(info)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def remove(self, name: str) -> None:
|
||||||
|
"""Remove an entry from the registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Key to remove. No-op if not found.
|
||||||
|
"""
|
||||||
|
if name in self._data:
|
||||||
|
del self._data[name]
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def list(self) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Return a copy of all registered entries.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping names to metadata copies.
|
||||||
|
"""
|
||||||
|
return {name: copy.deepcopy(info) for name, info in self._data.items()}
|
||||||
|
|
||||||
|
def update(self, name: str, **fields: Any) -> None:
|
||||||
|
"""Update fields for an existing entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Key to update.
|
||||||
|
**fields: Fields to update (None values are skipped).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If the entry doesn't exist.
|
||||||
|
"""
|
||||||
|
if name not in self._data:
|
||||||
|
raise KeyError(name)
|
||||||
|
self._data[name].update({k: v for k, v in fields.items() if v is not None})
|
||||||
|
self._save()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""XDG-compliant logging configuration for CLI tools.
|
||||||
|
|
||||||
|
Provides a standardised logging setup that writes to
|
||||||
|
``~/.local/state/<app>/logs/<app>.log`` following the XDG state
|
||||||
|
directory specification. Console output is handled separately by
|
||||||
|
the application (e.g. via ``click.echo``).
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from devx.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("myapp")
|
||||||
|
logger.info("Application started")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str = "devx") -> logging.Logger:
|
||||||
|
"""Return a configured logger that writes to an XDG state directory.
|
||||||
|
|
||||||
|
All messages (including DEBUG) are written to
|
||||||
|
``~/.local/state/<name>/logs/<name>.log``. Console output is
|
||||||
|
expected to be handled by the application via ``click.echo``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Logger name and subdirectory name for log files.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A configured :class:`logging.Logger` instance.
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger(name)
|
||||||
|
if logger.handlers:
|
||||||
|
return logger
|
||||||
|
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
log_dir = Path.home() / ".local" / "state" / name / "logs"
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_handler = logging.FileHandler(log_dir / f"{name}.log")
|
||||||
|
file_handler.setLevel(logging.DEBUG)
|
||||||
|
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
return logger
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Network connectivity helpers.
|
||||||
|
|
||||||
|
Provides retry-aware HTTP connectivity checks and SSH availability
|
||||||
|
checks for deployment workflows. Uses ``tenacity`` for exponential
|
||||||
|
backoff retry logic.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from devx.utils.network import check_http_connectivity, wait_for_ssh
|
||||||
|
|
||||||
|
check_http_connectivity("https://auth.example.com")
|
||||||
|
wait_for_ssh("178.105.254.83")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from tenacity import (
|
||||||
|
Retrying,
|
||||||
|
before_sleep_log,
|
||||||
|
retry_if_exception_type,
|
||||||
|
stop_after_attempt,
|
||||||
|
wait_exponential,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_http_connectivity(
|
||||||
|
base_url: str,
|
||||||
|
max_attempts: int = 30,
|
||||||
|
*,
|
||||||
|
verify: bool = True,
|
||||||
|
sleep: Callable[[float], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Verify HTTP reachability of *base_url* with retry.
|
||||||
|
|
||||||
|
Uses tenacity for retry with exponential backoff (2 s min, 10 s max).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: URL to check via GET request.
|
||||||
|
max_attempts: Maximum retry attempts.
|
||||||
|
verify: Whether to verify TLS certificates.
|
||||||
|
sleep: Custom sleep function for testing (defaults to ``time.sleep``).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
requests.exceptions.ConnectionError: If the URL is not reachable
|
||||||
|
after *max_attempts*.
|
||||||
|
"""
|
||||||
|
retrying = Retrying(
|
||||||
|
stop=stop_after_attempt(max_attempts),
|
||||||
|
wait=wait_exponential(multiplier=2, min=2, max=10),
|
||||||
|
retry=retry_if_exception_type(requests.exceptions.ConnectionError),
|
||||||
|
before_sleep=before_sleep_log(logging.getLogger("devx.utils.network"), logging.WARNING),
|
||||||
|
sleep=sleep if sleep is not None else time.sleep,
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check() -> None:
|
||||||
|
requests.get(base_url, timeout=10, verify=verify) # nosec B501
|
||||||
|
|
||||||
|
retrying(_check)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_ssh(
|
||||||
|
host: str,
|
||||||
|
port: int = 22,
|
||||||
|
max_attempts: int = 30,
|
||||||
|
interval: int = 10,
|
||||||
|
*,
|
||||||
|
sleep: Callable[[float], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Wait for SSH to be available on a host using a pure-Python socket check.
|
||||||
|
|
||||||
|
Uses socket instead of ``nc(1)`` so it works on CI runners without
|
||||||
|
netcat. Uses exponential backoff: starts at 2 s, doubles each
|
||||||
|
attempt up to 10 s max.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: VM IP address or hostname.
|
||||||
|
port: SSH port (default 22).
|
||||||
|
max_attempts: Maximum number of connection attempts.
|
||||||
|
interval: Base interval for backoff calculation (seconds).
|
||||||
|
sleep: Custom sleep function for testing (defaults to ``time.sleep``).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If SSH is not available after *max_attempts*.
|
||||||
|
"""
|
||||||
|
_sleep = sleep if sleep is not None else time.sleep
|
||||||
|
for i in range(max_attempts):
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=5):
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if i < max_attempts - 1:
|
||||||
|
wait = min(2 * (2**i), 10)
|
||||||
|
_sleep(wait)
|
||||||
|
raise RuntimeError(f"SSH not available on {host}:{port} after {max_attempts} attempts")
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""SSH helpers for running commands on remote hosts.
|
||||||
|
|
||||||
|
Provides a simple wrapper around the ``ssh`` CLI for executing commands
|
||||||
|
on remote machines (e.g. customer VMs, CI runners) without requiring
|
||||||
|
Ansible. Includes a pure-Python ``wait_for_ssh`` that uses socket
|
||||||
|
instead of ``nc(1)`` so it works on minimal CI containers.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from devx.utils.ssh import ssh_exec, wait_for_ssh
|
||||||
|
|
||||||
|
wait_for_ssh("178.105.254.83")
|
||||||
|
result = ssh_exec("178.105.254.83", "uname -a")
|
||||||
|
print(result.stdout)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import subprocess # nosec B404
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
SSH_CONNECT_TIMEOUT = "10"
|
||||||
|
SSH_HOST_KEY_CHECKING = "no"
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_exec(
|
||||||
|
host: str,
|
||||||
|
command: str,
|
||||||
|
*,
|
||||||
|
user: str = "deploy",
|
||||||
|
timeout: int = 30,
|
||||||
|
check: bool = True,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Run *command* on *host* via SSH and return the result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: VM IP address or hostname.
|
||||||
|
command: Shell command to execute on the remote host.
|
||||||
|
user: SSH user (default ``deploy``).
|
||||||
|
timeout: Subprocess timeout in seconds.
|
||||||
|
check: If True, raise ``CalledProcessError`` on non-zero exit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The completed process result with stdout/stderr captured.
|
||||||
|
"""
|
||||||
|
result = subprocess.run( # nosec B603, B607, B607
|
||||||
|
[
|
||||||
|
"ssh",
|
||||||
|
"-o",
|
||||||
|
f"StrictHostKeyChecking={SSH_HOST_KEY_CHECKING}",
|
||||||
|
"-o",
|
||||||
|
f"ConnectTimeout={SSH_CONNECT_TIMEOUT}",
|
||||||
|
f"{user}@{host}",
|
||||||
|
command,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if check and result.returncode != 0:
|
||||||
|
print(f"SSH command failed on {host}: {command}", file=sys.stderr)
|
||||||
|
print(f" stdout: {result.stdout.strip()}", file=sys.stderr)
|
||||||
|
print(f" stderr: {result.stderr.strip()}", file=sys.stderr)
|
||||||
|
result.check_returncode()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def docker_exec_on_vm(
|
||||||
|
host: str,
|
||||||
|
container: str,
|
||||||
|
command: str,
|
||||||
|
*,
|
||||||
|
user: str = "deploy",
|
||||||
|
db_user: str | None = None,
|
||||||
|
db_name: str | None = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> str:
|
||||||
|
"""Run a command inside a Docker container on a remote VM via SSH.
|
||||||
|
|
||||||
|
For PostgreSQL commands, set *db_user* and *db_name* to run
|
||||||
|
``psql -U <db_user> -d <db_name> -c <command>`` inside the container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: VM IP address or hostname.
|
||||||
|
container: Docker container name on the remote host.
|
||||||
|
command: Command to execute inside the container (or SQL if db_user/db_name set).
|
||||||
|
user: SSH user (default ``deploy``).
|
||||||
|
db_user: PostgreSQL user name (enables psql mode).
|
||||||
|
db_name: PostgreSQL database name (enables psql mode).
|
||||||
|
timeout: Subprocess timeout in seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Stripped stdout from the command.
|
||||||
|
"""
|
||||||
|
if db_user and db_name:
|
||||||
|
escaped_sql = command.replace("'", "'\"'\"'")
|
||||||
|
remote_cmd = f'docker exec {container} psql -U {db_user} -d {db_name} -t -A -c "{escaped_sql}"'
|
||||||
|
else:
|
||||||
|
remote_cmd = f"docker exec {container} {command}"
|
||||||
|
result = ssh_exec(host, remote_cmd, user=user, timeout=timeout)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_ssh(host: str, port: int = 22, max_attempts: int = 30, interval: int = 10) -> None:
|
||||||
|
"""Wait for SSH to be available on a host using a pure-Python socket check.
|
||||||
|
|
||||||
|
Uses socket instead of ``nc(1)`` so it works on CI runners without
|
||||||
|
netcat. Uses exponential backoff: starts at 2 s, doubles each
|
||||||
|
attempt up to 10 s max.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: VM IP address or hostname.
|
||||||
|
port: SSH port (default 22).
|
||||||
|
max_attempts: Maximum number of connection attempts.
|
||||||
|
interval: Base interval for backoff calculation (seconds).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If SSH is not available after *max_attempts*.
|
||||||
|
"""
|
||||||
|
for i in range(max_attempts):
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=5):
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if i < max_attempts - 1:
|
||||||
|
wait = min(2 * (2**i), 10)
|
||||||
|
time.sleep(wait)
|
||||||
|
raise RuntimeError(f"SSH not available on {host}:{port} after {max_attempts} attempts")
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Operation step tracking with translated reports.
|
||||||
|
|
||||||
|
Provides a context manager that tracks multi-step operations and prints
|
||||||
|
a status report on exit. Steps are marked as pending, in_progress,
|
||||||
|
completed, or failed. On exception, the last in-progress step is
|
||||||
|
marked as failed.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from devx.utils.step_tracker import track_steps
|
||||||
|
|
||||||
|
with track_steps() as tracker:
|
||||||
|
tracker.begin("Install dependencies")
|
||||||
|
install_deps()
|
||||||
|
tracker.done()
|
||||||
|
|
||||||
|
tracker.begin("Run tests")
|
||||||
|
run_tests()
|
||||||
|
tracker.done()
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
_STATUS_ICONS = {
|
||||||
|
"completed": "✓",
|
||||||
|
"failed": "✗",
|
||||||
|
"pending": "○",
|
||||||
|
"in_progress": "◌",
|
||||||
|
}
|
||||||
|
|
||||||
|
_STATUS_COLORS = {
|
||||||
|
"completed": "green",
|
||||||
|
"failed": "red",
|
||||||
|
"in_progress": "yellow",
|
||||||
|
"pending": "white",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Step:
|
||||||
|
"""A single tracked step in an operation."""
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.status = "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class StepTracker:
|
||||||
|
"""Tracks steps of an operation and prints a report on exit."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.steps: list[Step] = []
|
||||||
|
|
||||||
|
def begin(self, name: str) -> None:
|
||||||
|
"""Start a new step.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Human-readable step name.
|
||||||
|
"""
|
||||||
|
step = Step(name)
|
||||||
|
self.steps.append(step)
|
||||||
|
step.status = "in_progress"
|
||||||
|
|
||||||
|
def done(self) -> None:
|
||||||
|
"""Mark the most recent in-progress step as completed."""
|
||||||
|
if self.steps and self.steps[-1].status == "in_progress":
|
||||||
|
self.steps[-1].status = "completed"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def track_steps() -> Generator[StepTracker, None, None]:
|
||||||
|
"""Context manager that tracks steps and prints a report on exit.
|
||||||
|
|
||||||
|
On exception the last in-progress step is marked as failed.
|
||||||
|
The report is printed in the ``finally`` block so it always appears.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
A :class:`StepTracker` instance to track steps with.
|
||||||
|
"""
|
||||||
|
tracker = StepTracker()
|
||||||
|
try:
|
||||||
|
yield tracker
|
||||||
|
except Exception:
|
||||||
|
for step in reversed(tracker.steps):
|
||||||
|
if step.status == "in_progress":
|
||||||
|
step.status = "failed"
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
_print_report(tracker.steps)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_report(steps: list[Step]) -> None:
|
||||||
|
"""Print an operation report to stdout."""
|
||||||
|
click.secho("=== Operation Report ===", fg="bright_cyan")
|
||||||
|
for step in steps:
|
||||||
|
icon = _STATUS_ICONS.get(step.status, "?")
|
||||||
|
color = _STATUS_COLORS.get(step.status)
|
||||||
|
click.secho(f" {icon} {step.name} ({step.status})", fg=color)
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Ansible Vault helpers for encrypting and decrypting YAML files.
|
||||||
|
|
||||||
|
Wraps ``ansible-vault`` to provide a convenient API for loading and
|
||||||
|
saving vault-encrypted YAML files. Falls back to plain YAML when no
|
||||||
|
vault-password file is available, making it safe to use in both
|
||||||
|
local (with vault) and CI (without vault) environments.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from devx.utils.vault import load_vault_yaml, save_vault_yaml
|
||||||
|
|
||||||
|
data = load_vault_yaml(Path("secrets.yml"), vault_pass=Path("vault-password"))
|
||||||
|
data["new_key"] = "value"
|
||||||
|
save_vault_yaml(Path("secrets.yml"), data, vault_pass=Path("vault-password"))
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess # nosec B404
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_file(path: Path, vault_pass: Path) -> None:
|
||||||
|
"""Encrypt a file in-place using ansible-vault.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: File to encrypt.
|
||||||
|
vault_pass: Path to the vault-password file.
|
||||||
|
"""
|
||||||
|
subprocess.run( # nosec B603, B607
|
||||||
|
[
|
||||||
|
"ansible-vault",
|
||||||
|
"encrypt",
|
||||||
|
str(path),
|
||||||
|
"--vault-password-file",
|
||||||
|
str(vault_pass),
|
||||||
|
"--encrypt-vault-id",
|
||||||
|
"default",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_file(path: Path, vault_pass: Path) -> None:
|
||||||
|
"""Decrypt a file in-place using ansible-vault.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: File to decrypt.
|
||||||
|
vault_pass: Path to the vault-password file.
|
||||||
|
"""
|
||||||
|
subprocess.run( # nosec B603, B607
|
||||||
|
[
|
||||||
|
"ansible-vault",
|
||||||
|
"decrypt",
|
||||||
|
str(path),
|
||||||
|
"--vault-password-file",
|
||||||
|
str(vault_pass),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_vault_yaml(path: Path, vault_pass: Path | None = None) -> dict:
|
||||||
|
"""Load a YAML file, decrypting with ansible-vault if vault-password exists.
|
||||||
|
|
||||||
|
If *vault_pass* is None or doesn't exist, the file is read as plain
|
||||||
|
YAML. If decryption fails (file not vault-encrypted), it falls back
|
||||||
|
to plain YAML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: YAML file path.
|
||||||
|
vault_pass: Path to the vault-password file (optional).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed YAML content as a dict (empty dict if file is empty).
|
||||||
|
"""
|
||||||
|
if vault_pass is None or not vault_pass.exists():
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f) or {}
|
||||||
|
result = subprocess.run( # nosec B603, B607
|
||||||
|
["ansible-vault", "view", str(path), "--vault-password-file", str(vault_pass)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return yaml.safe_load(result.stdout) or {}
|
||||||
|
if "is not vault encrypted" in result.stderr:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f) or {}
|
||||||
|
result.check_returncode() # pragma: no cover
|
||||||
|
return {} # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
|
def save_vault_yaml(path: Path, data: dict, vault_pass: Path | None = None) -> None:
|
||||||
|
"""Write YAML data, encrypting with ansible-vault if vault-password exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Destination YAML file path.
|
||||||
|
data: Data to serialize.
|
||||||
|
vault_pass: Path to the vault-password file (optional).
|
||||||
|
"""
|
||||||
|
plain = yaml.dump(data, default_flow_style=False, sort_keys=False)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(plain)
|
||||||
|
if vault_pass is not None and vault_pass.exists():
|
||||||
|
subprocess.run( # nosec B603, B607
|
||||||
|
[
|
||||||
|
"ansible-vault",
|
||||||
|
"encrypt",
|
||||||
|
str(path),
|
||||||
|
"--vault-password-file",
|
||||||
|
str(vault_pass),
|
||||||
|
"--encrypt-vault-id",
|
||||||
|
"default",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_encrypted(path: Path) -> bool:
|
||||||
|
"""Check if a file is ansible-vault encrypted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: File to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the file starts with the ``$ANSIBLE_VAULT`` marker.
|
||||||
|
"""
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
first_line = f.readline()
|
||||||
|
return "$ANSIBLE_VAULT" in first_line
|
||||||
@@ -292,3 +292,18 @@ class TestCli:
|
|||||||
)
|
)
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "does not match Vikunja" in result.output
|
assert "does not match Vikunja" in result.output
|
||||||
|
|
||||||
|
def test_fails_with_double_prefix_in_vikunja_title(self) -> None:
|
||||||
|
"""Vikunja title with task ID prefix causes double-prefix in PR title."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||||
|
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="DEVX-1: Fix foo"),
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "should NOT include" in result.output
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Unit tests for devx.ci.record_deployed_tag."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.ci.record_deployed_tag import main
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecordDeployedTag:
|
||||||
|
@patch("devx.ci.record_deployed_tag.GiteaClient")
|
||||||
|
@patch("devx.ci.record_deployed_tag.get_ci_token")
|
||||||
|
def test_records_production_tag(self, mock_token: MagicMock, mock_client: MagicMock) -> None:
|
||||||
|
mock_token.return_value = "fake-token"
|
||||||
|
client_instance = MagicMock()
|
||||||
|
mock_client.return_value = client_instance
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--env", "production", "--tag", "v1.0.0"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "PRODUCTION_DEPLOY_TAG" in result.output
|
||||||
|
assert "v1.0.0" in result.output
|
||||||
|
client_instance.set_repo_variable.assert_called_once_with("PRODUCTION_DEPLOY_TAG", "v1.0.0")
|
||||||
|
|
||||||
|
@patch("devx.ci.record_deployed_tag.GiteaClient")
|
||||||
|
@patch("devx.ci.record_deployed_tag.get_ci_token")
|
||||||
|
def test_records_staging_tag(self, mock_token: MagicMock, mock_client: MagicMock) -> None:
|
||||||
|
mock_token.return_value = "fake-token"
|
||||||
|
client_instance = MagicMock()
|
||||||
|
mock_client.return_value = client_instance
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--env", "staging", "--tag", "master-abc123"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "STAGING_DEPLOY_TAG" in result.output
|
||||||
|
client_instance.set_repo_variable.assert_called_once_with("STAGING_DEPLOY_TAG", "master-abc123")
|
||||||
|
|
||||||
|
@patch("devx.ci.record_deployed_tag.get_ci_token")
|
||||||
|
def test_token_error_exits_nonzero(self, mock_token: MagicMock) -> None:
|
||||||
|
import click
|
||||||
|
|
||||||
|
mock_token.side_effect = click.ClickException("No token available")
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--env", "production", "--tag", "v1.0.0"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "No token available" in result.output
|
||||||
|
|
||||||
|
def test_invalid_env_choice(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--env", "invalid", "--tag", "v1.0.0"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
def test_missing_tag_option(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--env", "production"])
|
||||||
|
assert result.exit_code != 0
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Unit tests for devx.utils.confirm."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from devx.utils.confirm import validate_confirmation
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateConfirmation:
|
||||||
|
def test_exact_match(self) -> None:
|
||||||
|
assert validate_confirmation("deploy-production", "deploy-production") is True
|
||||||
|
|
||||||
|
def test_mismatch(self) -> None:
|
||||||
|
assert validate_confirmation("deploy-staging", "deploy-production") is False
|
||||||
|
|
||||||
|
def test_empty_string(self) -> None:
|
||||||
|
assert validate_confirmation("", "deploy-production") is False
|
||||||
|
|
||||||
|
def test_case_sensitive(self) -> None:
|
||||||
|
assert validate_confirmation("Deploy-Production", "deploy-production") is False
|
||||||
|
|
||||||
|
def test_partial_match(self) -> None:
|
||||||
|
assert validate_confirmation("deploy", "deploy-production") is False
|
||||||
|
|
||||||
|
def test_extra_whitespace(self) -> None:
|
||||||
|
assert validate_confirmation("deploy-production ", "deploy-production") is False
|
||||||
|
|
||||||
|
def test_custom_expected(self) -> None:
|
||||||
|
assert validate_confirmation("yes-delete-all", "yes-delete-all") is True
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Unit tests for devx.utils.crypto."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from devx.utils.crypto import (
|
||||||
|
_DIGITS,
|
||||||
|
_LOWER,
|
||||||
|
_SYMBOLS,
|
||||||
|
_UPPER,
|
||||||
|
generate_hex_secret,
|
||||||
|
generate_password,
|
||||||
|
generate_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateSecret:
|
||||||
|
def test_returns_url_safe_string(self) -> None:
|
||||||
|
secret = generate_secret()
|
||||||
|
assert isinstance(secret, str)
|
||||||
|
assert len(secret) > 0
|
||||||
|
# URL-safe base64 characters only
|
||||||
|
assert re.match(r"^[A-Za-z0-9_-]+$", secret)
|
||||||
|
|
||||||
|
def test_never_starts_with_dash(self) -> None:
|
||||||
|
for _ in range(1000):
|
||||||
|
secret = generate_secret()
|
||||||
|
assert not secret.startswith("-")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeneratePassword:
|
||||||
|
def test_default_length(self) -> None:
|
||||||
|
pw = generate_password()
|
||||||
|
assert len(pw) == 32
|
||||||
|
|
||||||
|
def test_custom_length(self) -> None:
|
||||||
|
pw = generate_password(length=64)
|
||||||
|
assert len(pw) == 64
|
||||||
|
|
||||||
|
def test_contains_all_char_classes(self) -> None:
|
||||||
|
pw = generate_password(length=32)
|
||||||
|
assert any(c in _UPPER for c in pw), "Missing uppercase"
|
||||||
|
assert any(c in _LOWER for c in pw), "Missing lowercase"
|
||||||
|
assert any(c in _DIGITS for c in pw), "Missing digits"
|
||||||
|
assert any(c in _SYMBOLS for c in pw), "Missing symbols"
|
||||||
|
|
||||||
|
def test_first_char_alphanumeric(self) -> None:
|
||||||
|
for _ in range(1000):
|
||||||
|
pw = generate_password()
|
||||||
|
assert pw[0] not in _SYMBOLS, f"First char '{pw[0]}' is a symbol"
|
||||||
|
|
||||||
|
def test_minimum_length_4(self) -> None:
|
||||||
|
pw = generate_password(length=4)
|
||||||
|
assert len(pw) == 4
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateHexSecret:
|
||||||
|
def test_returns_hex_string(self) -> None:
|
||||||
|
secret = generate_hex_secret(length=32)
|
||||||
|
assert re.match(r"^[0-9a-f]+$", secret)
|
||||||
|
|
||||||
|
def test_correct_length(self) -> None:
|
||||||
|
secret = generate_hex_secret(length=20)
|
||||||
|
assert len(secret) == 20
|
||||||
|
|
||||||
|
def test_empty_for_zero(self) -> None:
|
||||||
|
secret = generate_hex_secret(length=0)
|
||||||
|
assert secret == ""
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Unit tests for devx.utils.json_registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from devx.utils.json_registry import JsonRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonRegistry:
|
||||||
|
def test_add_and_get(self, tmp_path: Path) -> None:
|
||||||
|
reg = JsonRegistry(tmp_path / "state.json")
|
||||||
|
reg.add("item1", host="10.0.0.1", user="deploy")
|
||||||
|
info = reg.get("item1")
|
||||||
|
assert info is not None
|
||||||
|
assert info["host"] == "10.0.0.1"
|
||||||
|
assert info["user"] == "deploy"
|
||||||
|
assert "created_at" in info
|
||||||
|
|
||||||
|
def test_get_nonexistent(self, tmp_path: Path) -> None:
|
||||||
|
reg = JsonRegistry(tmp_path / "state.json")
|
||||||
|
assert reg.get("nope") is None
|
||||||
|
|
||||||
|
def test_remove(self, tmp_path: Path) -> None:
|
||||||
|
reg = JsonRegistry(tmp_path / "state.json")
|
||||||
|
reg.add("item1", host="10.0.0.1")
|
||||||
|
reg.remove("item1")
|
||||||
|
assert reg.get("item1") is None
|
||||||
|
|
||||||
|
def test_remove_nonexistent_is_noop(self, tmp_path: Path) -> None:
|
||||||
|
reg = JsonRegistry(tmp_path / "state.json")
|
||||||
|
reg.remove("nonexistent") # should not raise
|
||||||
|
|
||||||
|
def test_list(self, tmp_path: Path) -> None:
|
||||||
|
reg = JsonRegistry(tmp_path / "state.json")
|
||||||
|
reg.add("a", host="1.1.1.1")
|
||||||
|
reg.add("b", host="2.2.2.2")
|
||||||
|
items = reg.list()
|
||||||
|
assert set(items.keys()) == {"a", "b"}
|
||||||
|
assert items["a"]["host"] == "1.1.1.1"
|
||||||
|
|
||||||
|
def test_list_empty(self, tmp_path: Path) -> None:
|
||||||
|
reg = JsonRegistry(tmp_path / "state.json")
|
||||||
|
assert reg.list() == {}
|
||||||
|
|
||||||
|
def test_update_existing(self, tmp_path: Path) -> None:
|
||||||
|
reg = JsonRegistry(tmp_path / "state.json")
|
||||||
|
reg.add("item", host="1.1.1.1", status="active")
|
||||||
|
reg.update("item", status="inactive")
|
||||||
|
info = reg.get("item")
|
||||||
|
assert info["status"] == "inactive"
|
||||||
|
assert info["host"] == "1.1.1.1" # unchanged
|
||||||
|
|
||||||
|
def test_update_nonexistent_raises(self, tmp_path: Path) -> None:
|
||||||
|
reg = JsonRegistry(tmp_path / "state.json")
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
reg.update("nonexistent", host="1.1.1.1")
|
||||||
|
|
||||||
|
def test_update_skips_none_values(self, tmp_path: Path) -> None:
|
||||||
|
reg = JsonRegistry(tmp_path / "state.json")
|
||||||
|
reg.add("item", host="1.1.1.1")
|
||||||
|
reg.update("item", host=None, status="active")
|
||||||
|
info = reg.get("item")
|
||||||
|
assert info["host"] == "1.1.1.1" # not overwritten by None
|
||||||
|
assert info["status"] == "active"
|
||||||
|
|
||||||
|
def test_persistence_across_instances(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "state.json"
|
||||||
|
reg1 = JsonRegistry(path)
|
||||||
|
reg1.add("item", host="10.0.0.1")
|
||||||
|
reg2 = JsonRegistry(path)
|
||||||
|
info = reg2.get("item")
|
||||||
|
assert info is not None
|
||||||
|
assert info["host"] == "10.0.0.1"
|
||||||
|
|
||||||
|
def test_overwrite_existing(self, tmp_path: Path) -> None:
|
||||||
|
reg = JsonRegistry(tmp_path / "state.json")
|
||||||
|
reg.add("item", host="1.1.1.1")
|
||||||
|
reg.add("item", host="2.2.2.2")
|
||||||
|
info = reg.get("item")
|
||||||
|
assert info["host"] == "2.2.2.2"
|
||||||
|
|
||||||
|
def test_corrupt_json_returns_empty(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "state.json"
|
||||||
|
path.write_text("{invalid json")
|
||||||
|
reg = JsonRegistry(path)
|
||||||
|
assert reg.list() == {}
|
||||||
|
|
||||||
|
def test_nonexistent_file_returns_empty(self, tmp_path: Path) -> None:
|
||||||
|
reg = JsonRegistry(tmp_path / "nonexistent.json")
|
||||||
|
assert reg.list() == {}
|
||||||
|
|
||||||
|
def test_creates_parent_dirs(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "subdir" / "deeper" / "state.json"
|
||||||
|
reg = JsonRegistry(path)
|
||||||
|
reg.add("item", host="1.1.1.1")
|
||||||
|
assert path.exists()
|
||||||
|
|
||||||
|
def test_get_returns_copy(self, tmp_path: Path) -> None:
|
||||||
|
reg = JsonRegistry(tmp_path / "state.json")
|
||||||
|
reg.add("item", host="1.1.1.1", tags=["a", "b"])
|
||||||
|
info = reg.get("item")
|
||||||
|
assert info is not None
|
||||||
|
info["tags"].append("c")
|
||||||
|
# Original should be unchanged
|
||||||
|
info2 = reg.get("item")
|
||||||
|
assert info2 is not None
|
||||||
|
assert info2["tags"] == ["a", "b"]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Unit tests for devx.utils.logging."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from devx.utils.logging import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLogger:
|
||||||
|
def test_returns_logger_with_handlers(self) -> None:
|
||||||
|
logger = get_logger("test_devx_unit_1")
|
||||||
|
assert logger.handlers
|
||||||
|
assert isinstance(logger.handlers[0], logging.FileHandler)
|
||||||
|
|
||||||
|
def test_idempotent(self) -> None:
|
||||||
|
logger1 = get_logger("test_devx_unit_2")
|
||||||
|
initial_count = len(logger1.handlers)
|
||||||
|
logger2 = get_logger("test_devx_unit_2")
|
||||||
|
assert logger1 is logger2
|
||||||
|
assert len(logger2.handlers) == initial_count
|
||||||
|
|
||||||
|
def test_log_level_is_debug(self) -> None:
|
||||||
|
logger = get_logger("test_devx_unit_3")
|
||||||
|
assert logger.level == logging.DEBUG
|
||||||
|
|
||||||
|
def test_file_handler_level_is_debug(self) -> None:
|
||||||
|
logger = get_logger("test_devx_unit_4")
|
||||||
|
file_handler = logger.handlers[0]
|
||||||
|
assert file_handler.level == logging.DEBUG
|
||||||
|
|
||||||
|
def test_default_name(self) -> None:
|
||||||
|
logger = get_logger()
|
||||||
|
assert logger.name == "devx"
|
||||||
|
|
||||||
|
def test_creates_log_directory(self, tmp_path: Path) -> None:
|
||||||
|
with patch.object(Path, "home", return_value=tmp_path):
|
||||||
|
get_logger("test_app_creates_dir")
|
||||||
|
log_dir = tmp_path / ".local" / "state" / "test_app_creates_dir" / "logs"
|
||||||
|
assert log_dir.exists()
|
||||||
|
assert (log_dir / "test_app_creates_dir.log").exists()
|
||||||
|
|
||||||
|
def test_formatter_includes_timestamp(self) -> None:
|
||||||
|
logger = get_logger("test_devx_unit_5")
|
||||||
|
file_handler = logger.handlers[0]
|
||||||
|
fmt = file_handler.formatter
|
||||||
|
assert fmt is not None
|
||||||
|
assert "%(asctime)s" in fmt._fmt
|
||||||
|
assert "%(levelname)s" in fmt._fmt
|
||||||
|
assert "%(name)s" in fmt._fmt
|
||||||
|
assert "%(message)s" in fmt._fmt
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Unit tests for devx.utils.network."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from devx.utils.network import check_http_connectivity, wait_for_ssh
|
||||||
|
|
||||||
|
_no_sleep = MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckHttpConnectivity:
|
||||||
|
@patch("devx.utils.network.requests.get")
|
||||||
|
def test_success(self, mock_get: MagicMock) -> None:
|
||||||
|
mock_get.return_value = MagicMock(status_code=200)
|
||||||
|
check_http_connectivity("https://example.com", max_attempts=3)
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
|
||||||
|
@patch("devx.utils.network.requests.get")
|
||||||
|
def test_retries_on_connection_error(self, mock_get: MagicMock) -> None:
|
||||||
|
mock_get.side_effect = [
|
||||||
|
requests.exceptions.ConnectionError("refused"),
|
||||||
|
requests.exceptions.ConnectionError("refused"),
|
||||||
|
MagicMock(status_code=200),
|
||||||
|
]
|
||||||
|
check_http_connectivity("https://example.com", max_attempts=5, sleep=_no_sleep)
|
||||||
|
assert mock_get.call_count == 3
|
||||||
|
|
||||||
|
@patch("devx.utils.network.requests.get")
|
||||||
|
def test_raises_after_max_attempts(self, mock_get: MagicMock) -> None:
|
||||||
|
mock_get.side_effect = requests.exceptions.ConnectionError("refused")
|
||||||
|
with pytest.raises(requests.exceptions.ConnectionError):
|
||||||
|
check_http_connectivity("https://example.com", max_attempts=2, sleep=_no_sleep)
|
||||||
|
assert mock_get.call_count == 2
|
||||||
|
|
||||||
|
@patch("devx.utils.network.requests.get")
|
||||||
|
def test_verify_false(self, mock_get: MagicMock) -> None:
|
||||||
|
mock_get.return_value = MagicMock(status_code=200)
|
||||||
|
check_http_connectivity("https://example.com", verify=False)
|
||||||
|
mock_get.assert_called_once_with("https://example.com", timeout=10, verify=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWaitForSsh:
|
||||||
|
@patch("devx.utils.network.socket.create_connection")
|
||||||
|
def test_immediate_success(self, mock_conn: MagicMock) -> None:
|
||||||
|
mock_conn.return_value.__enter__ = MagicMock()
|
||||||
|
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
wait_for_ssh("10.0.0.1")
|
||||||
|
mock_conn.assert_called_once()
|
||||||
|
|
||||||
|
@patch("devx.utils.network.socket.create_connection")
|
||||||
|
def test_retries_until_success(self, mock_conn: MagicMock) -> None:
|
||||||
|
mock_conn.side_effect = [
|
||||||
|
OSError("refused"),
|
||||||
|
OSError("refused"),
|
||||||
|
MagicMock(),
|
||||||
|
]
|
||||||
|
mock_conn.return_value.__enter__ = MagicMock()
|
||||||
|
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
wait_for_ssh("10.0.0.1", max_attempts=5, sleep=_no_sleep)
|
||||||
|
assert mock_conn.call_count == 3
|
||||||
|
|
||||||
|
@patch("devx.utils.network.socket.create_connection")
|
||||||
|
def test_timeout_after_max_attempts(self, mock_conn: MagicMock) -> None:
|
||||||
|
mock_conn.side_effect = OSError("refused")
|
||||||
|
with pytest.raises(RuntimeError, match="SSH not available"):
|
||||||
|
wait_for_ssh("10.0.0.1", max_attempts=3, sleep=_no_sleep)
|
||||||
|
assert mock_conn.call_count == 3
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Unit tests for devx.utils.ssh."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from devx.utils.ssh import docker_exec_on_vm, ssh_exec, wait_for_ssh
|
||||||
|
|
||||||
|
|
||||||
|
class TestSshExec:
|
||||||
|
@patch("devx.utils.ssh.subprocess.run")
|
||||||
|
def test_success(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||||
|
result = ssh_exec("10.0.0.1", "uname -a")
|
||||||
|
assert result.returncode == 0
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
|
||||||
|
@patch("devx.utils.ssh.subprocess.run")
|
||||||
|
def test_failure_with_check(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_result = MagicMock(returncode=1, stdout="", stderr="error")
|
||||||
|
mock_result.check_returncode.side_effect = subprocess.CalledProcessError(1, "ssh")
|
||||||
|
mock_run.return_value = mock_result
|
||||||
|
with pytest.raises(subprocess.CalledProcessError):
|
||||||
|
ssh_exec("10.0.0.1", "false")
|
||||||
|
|
||||||
|
@patch("devx.utils.ssh.subprocess.run")
|
||||||
|
def test_failure_without_check(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||||
|
result = ssh_exec("10.0.0.1", "false", check=False)
|
||||||
|
assert result.returncode == 1
|
||||||
|
|
||||||
|
@patch("devx.utils.ssh.subprocess.run")
|
||||||
|
def test_custom_user(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
ssh_exec("10.0.0.1", "whoami", user="root")
|
||||||
|
cmd = mock_run.call_args[0][0]
|
||||||
|
assert "root@10.0.0.1" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
class TestDockerExecOnVm:
|
||||||
|
@patch("devx.utils.ssh.ssh_exec")
|
||||||
|
def test_simple_command(self, mock_ssh: MagicMock) -> None:
|
||||||
|
mock_ssh.return_value = MagicMock(stdout="output\n")
|
||||||
|
result = docker_exec_on_vm("10.0.0.1", "mycontainer", "ls /")
|
||||||
|
assert result == "output"
|
||||||
|
mock_ssh.assert_called_once_with("10.0.0.1", "docker exec mycontainer ls /", user="deploy", timeout=30)
|
||||||
|
|
||||||
|
@patch("devx.utils.ssh.ssh_exec")
|
||||||
|
def test_psql_mode(self, mock_ssh: MagicMock) -> None:
|
||||||
|
mock_ssh.return_value = MagicMock(stdout="result\n")
|
||||||
|
result = docker_exec_on_vm("10.0.0.1", "db", "SELECT 1", db_user="postgres", db_name="mydb")
|
||||||
|
assert result == "result"
|
||||||
|
call_args = mock_ssh.call_args[0][1]
|
||||||
|
assert "psql -U postgres -d mydb" in call_args
|
||||||
|
assert "SELECT 1" in call_args
|
||||||
|
|
||||||
|
@patch("devx.utils.ssh.ssh_exec")
|
||||||
|
def test_psql_escapes_single_quotes(self, mock_ssh: MagicMock) -> None:
|
||||||
|
mock_ssh.return_value = MagicMock(stdout="\n")
|
||||||
|
docker_exec_on_vm("10.0.0.1", "db", "SELECT 'it''s ok'", db_user="pg", db_name="db")
|
||||||
|
call_args = mock_ssh.call_args[0][1]
|
||||||
|
assert "'\"'\"'" in call_args
|
||||||
|
|
||||||
|
|
||||||
|
class TestWaitForSsh:
|
||||||
|
@patch("devx.utils.ssh.socket.create_connection")
|
||||||
|
def test_immediate_success(self, mock_conn: MagicMock) -> None:
|
||||||
|
mock_conn.return_value.__enter__ = MagicMock()
|
||||||
|
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
wait_for_ssh("10.0.0.1")
|
||||||
|
mock_conn.assert_called_once()
|
||||||
|
|
||||||
|
@patch("devx.utils.ssh.socket.create_connection")
|
||||||
|
@patch("devx.utils.ssh.time.sleep")
|
||||||
|
def test_retries_until_success(self, mock_sleep: MagicMock, mock_conn: MagicMock) -> None:
|
||||||
|
# Fail twice, then succeed
|
||||||
|
mock_conn.side_effect = [
|
||||||
|
OSError("refused"),
|
||||||
|
OSError("refused"),
|
||||||
|
MagicMock(),
|
||||||
|
]
|
||||||
|
mock_conn.return_value.__enter__ = MagicMock()
|
||||||
|
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
wait_for_ssh("10.0.0.1", max_attempts=5)
|
||||||
|
assert mock_conn.call_count == 3
|
||||||
|
|
||||||
|
@patch("devx.utils.ssh.socket.create_connection")
|
||||||
|
@patch("devx.utils.ssh.time.sleep")
|
||||||
|
def test_timeout_after_max_attempts(self, mock_sleep: MagicMock, mock_conn: MagicMock) -> None:
|
||||||
|
mock_conn.side_effect = OSError("refused")
|
||||||
|
with pytest.raises(RuntimeError, match="SSH not available"):
|
||||||
|
wait_for_ssh("10.0.0.1", max_attempts=3)
|
||||||
|
assert mock_conn.call_count == 3
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Unit tests for devx.utils.step_tracker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import click
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.utils.step_tracker import Step, StepTracker, track_steps
|
||||||
|
|
||||||
|
|
||||||
|
class TestStep:
|
||||||
|
def test_initial_status_is_pending(self) -> None:
|
||||||
|
step = Step("install")
|
||||||
|
assert step.status == "pending"
|
||||||
|
assert step.name == "install"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStepTracker:
|
||||||
|
def test_begin_adds_step_as_in_progress(self) -> None:
|
||||||
|
tracker = StepTracker()
|
||||||
|
tracker.begin("install deps")
|
||||||
|
assert len(tracker.steps) == 1
|
||||||
|
assert tracker.steps[0].status == "in_progress"
|
||||||
|
|
||||||
|
def test_done_marks_last_in_progress_as_completed(self) -> None:
|
||||||
|
tracker = StepTracker()
|
||||||
|
tracker.begin("step1")
|
||||||
|
tracker.done()
|
||||||
|
assert tracker.steps[0].status == "completed"
|
||||||
|
|
||||||
|
def test_done_no_op_if_no_in_progress(self) -> None:
|
||||||
|
tracker = StepTracker()
|
||||||
|
tracker.begin("step1")
|
||||||
|
tracker.done()
|
||||||
|
tracker.done() # should not raise, no-op
|
||||||
|
assert tracker.steps[0].status == "completed"
|
||||||
|
|
||||||
|
def test_done_no_op_if_empty(self) -> None:
|
||||||
|
tracker = StepTracker()
|
||||||
|
tracker.done() # should not raise
|
||||||
|
|
||||||
|
def test_multiple_steps(self) -> None:
|
||||||
|
tracker = StepTracker()
|
||||||
|
tracker.begin("step1")
|
||||||
|
tracker.done()
|
||||||
|
tracker.begin("step2")
|
||||||
|
tracker.done()
|
||||||
|
assert len(tracker.steps) == 2
|
||||||
|
assert tracker.steps[0].status == "completed"
|
||||||
|
assert tracker.steps[1].status == "completed"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrackSteps:
|
||||||
|
def test_successful_operation(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolation():
|
||||||
|
with track_steps() as tracker:
|
||||||
|
tracker.begin("step1")
|
||||||
|
tracker.done()
|
||||||
|
tracker.begin("step2")
|
||||||
|
tracker.done()
|
||||||
|
assert len(tracker.steps) == 2
|
||||||
|
assert all(s.status == "completed" for s in tracker.steps)
|
||||||
|
|
||||||
|
def test_exception_marks_in_progress_as_failed(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolation():
|
||||||
|
with pytest.raises(ValueError, match="boom"):
|
||||||
|
with track_steps() as tracker:
|
||||||
|
tracker.begin("step1")
|
||||||
|
tracker.done()
|
||||||
|
tracker.begin("step2")
|
||||||
|
raise ValueError("boom")
|
||||||
|
assert tracker.steps[0].status == "completed"
|
||||||
|
assert tracker.steps[1].status == "failed"
|
||||||
|
|
||||||
|
def test_pending_step_stays_pending_on_exception(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolation():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
with track_steps() as tracker:
|
||||||
|
tracker.begin("step1")
|
||||||
|
tracker.done()
|
||||||
|
tracker.begin("step2")
|
||||||
|
tracker.done()
|
||||||
|
tracker.begin("step3") # in_progress
|
||||||
|
# step4 is pending (not started)
|
||||||
|
raise ValueError("oops")
|
||||||
|
assert tracker.steps[2].status == "failed"
|
||||||
|
|
||||||
|
def test_empty_operation(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolation():
|
||||||
|
with track_steps() as tracker:
|
||||||
|
pass
|
||||||
|
assert tracker.steps == []
|
||||||
|
|
||||||
|
def test_report_printed_on_success(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(_cmd_success, [], color=False)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Operation Report" in result.output
|
||||||
|
assert "step1" in result.output
|
||||||
|
|
||||||
|
def test_report_printed_on_failure(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(_cmd_failure, [], color=False)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Operation Report" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
def _cmd_success() -> None:
|
||||||
|
with track_steps() as tracker:
|
||||||
|
tracker.begin("step1")
|
||||||
|
tracker.done()
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
def _cmd_failure() -> None:
|
||||||
|
with track_steps() as tracker:
|
||||||
|
tracker.begin("step1")
|
||||||
|
raise ValueError("oops")
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Unit tests for devx.utils.vault."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from devx.utils.vault import (
|
||||||
|
decrypt_file,
|
||||||
|
encrypt_file,
|
||||||
|
is_encrypted,
|
||||||
|
load_vault_yaml,
|
||||||
|
save_vault_yaml,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsEncrypted:
|
||||||
|
def test_encrypted_file(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "secret.yml"
|
||||||
|
f.write_text("$ANSIBLE_VAULT;1.1;AES256\n9382928...\n")
|
||||||
|
assert is_encrypted(f) is True
|
||||||
|
|
||||||
|
def test_plain_file(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "plain.yml"
|
||||||
|
f.write_text("key: value\n")
|
||||||
|
assert is_encrypted(f) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadVaultYaml:
|
||||||
|
def test_plain_yaml_no_vault_pass(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "data.yml"
|
||||||
|
f.write_text("key: value\nlist:\n - a\n - b\n")
|
||||||
|
data = load_vault_yaml(f)
|
||||||
|
assert data == {"key": "value", "list": ["a", "b"]}
|
||||||
|
|
||||||
|
def test_empty_file(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "empty.yml"
|
||||||
|
f.write_text("")
|
||||||
|
data = load_vault_yaml(f)
|
||||||
|
assert data == {}
|
||||||
|
|
||||||
|
def test_vault_pass_not_exists(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "data.yml"
|
||||||
|
f.write_text("key: value\n")
|
||||||
|
data = load_vault_yaml(f, vault_pass=tmp_path / "nonexistent")
|
||||||
|
assert data == {"key": "value"}
|
||||||
|
|
||||||
|
@patch("devx.utils.vault.subprocess.run")
|
||||||
|
def test_encrypted_file_success(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "secret.yml"
|
||||||
|
f.write_text("$ANSIBLE_VAULT\n...")
|
||||||
|
vp = tmp_path / "vault-password"
|
||||||
|
vp.write_text("secret")
|
||||||
|
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="key: decrypted\n", stderr="")
|
||||||
|
data = load_vault_yaml(f, vault_pass=vp)
|
||||||
|
assert data == {"key": "decrypted"}
|
||||||
|
|
||||||
|
@patch("devx.utils.vault.subprocess.run")
|
||||||
|
def test_not_vault_encrypted_fallback(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "plain.yml"
|
||||||
|
f.write_text("key: value\n")
|
||||||
|
vp = tmp_path / "vault-password"
|
||||||
|
vp.write_text("secret")
|
||||||
|
|
||||||
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="is not vault encrypted")
|
||||||
|
data = load_vault_yaml(f, vault_pass=vp)
|
||||||
|
assert data == {"key": "value"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveVaultYaml:
|
||||||
|
def test_save_plain(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "output.yml"
|
||||||
|
save_vault_yaml(f, {"key": "value"})
|
||||||
|
content = f.read_text()
|
||||||
|
assert "key: value" in content
|
||||||
|
|
||||||
|
def test_save_with_vault_pass_not_exists(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "output.yml"
|
||||||
|
vp = tmp_path / "nonexistent"
|
||||||
|
save_vault_yaml(f, {"key": "value"}, vault_pass=vp)
|
||||||
|
# Should save as plain YAML
|
||||||
|
content = f.read_text()
|
||||||
|
assert "key: value" in content
|
||||||
|
assert "$ANSIBLE_VAULT" not in content
|
||||||
|
|
||||||
|
@patch("devx.utils.vault.subprocess.run")
|
||||||
|
def test_save_and_encrypt(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "output.yml"
|
||||||
|
vp = tmp_path / "vault-password"
|
||||||
|
vp.write_text("secret")
|
||||||
|
|
||||||
|
save_vault_yaml(f, {"key": "value"}, vault_pass=vp)
|
||||||
|
# File should be written
|
||||||
|
assert f.exists()
|
||||||
|
# ansible-vault encrypt should be called
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
cmd = mock_run.call_args[0][0]
|
||||||
|
assert "ansible-vault" in cmd
|
||||||
|
assert "encrypt" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
class TestEncryptFile:
|
||||||
|
@patch("devx.utils.vault.subprocess.run")
|
||||||
|
def test_calls_ansible_vault(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "file.yml"
|
||||||
|
f.write_text("key: value")
|
||||||
|
vp = tmp_path / "vault-password"
|
||||||
|
vp.write_text("secret")
|
||||||
|
|
||||||
|
encrypt_file(f, vp)
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
cmd = mock_run.call_args[0][0]
|
||||||
|
assert "ansible-vault" in cmd
|
||||||
|
assert "encrypt" in cmd
|
||||||
|
assert str(f) in cmd
|
||||||
|
assert str(vp) in cmd
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecryptFile:
|
||||||
|
@patch("devx.utils.vault.subprocess.run")
|
||||||
|
def test_calls_ansible_vault(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "file.yml"
|
||||||
|
f.write_text("$ANSIBLE_VAULT\n...")
|
||||||
|
vp = tmp_path / "vault-password"
|
||||||
|
vp.write_text("secret")
|
||||||
|
|
||||||
|
decrypt_file(f, vp)
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
cmd = mock_run.call_args[0][0]
|
||||||
|
assert "ansible-vault" in cmd
|
||||||
|
assert "decrypt" in cmd
|
||||||
|
assert str(f) in cmd
|
||||||
|
assert str(vp) in cmd
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Unit tests for devx.ci.validate_deploy_ref."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.ci.validate_deploy_ref import main
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateDeployRef:
|
||||||
|
def test_valid_tag_prints_ref(self, tmp_path: Path) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="abcdef1234567890\n", stderr="")
|
||||||
|
result = runner.invoke(main, ["--tag", "v1.0.0"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "v1.0.0" in result.output
|
||||||
|
|
||||||
|
def test_invalid_tag_exits_nonzero(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||||
|
result = runner.invoke(main, ["--tag", "nonexistent"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "does not exist" in result.output
|
||||||
|
|
||||||
|
def test_no_tag_without_allow_empty_exits_nonzero(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, [])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "No tag specified" in result.output
|
||||||
|
|
||||||
|
def test_allow_empty_prints_pr_mode(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--allow-empty"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "PR mode" in result.output
|
||||||
|
|
||||||
|
def test_github_output_writes_ref(self, tmp_path: Path) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
gh_output = tmp_path / "github_output"
|
||||||
|
gh_output.write_text("")
|
||||||
|
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="abcdef12\n", stderr="")
|
||||||
|
with runner.isolation(env={"GITHUB_OUTPUT": str(gh_output)}):
|
||||||
|
result = runner.invoke(main, ["--tag", "v1.0.0", "--github-output"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = gh_output.read_text()
|
||||||
|
assert "deploy-ref=v1.0.0" in content
|
||||||
|
|
||||||
|
def test_github_output_without_env_var_exits_nonzero(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="abcdef12\n", stderr="")
|
||||||
|
with runner.isolation(env={"GITHUB_OUTPUT": ""}):
|
||||||
|
result = runner.invoke(main, ["--tag", "v1.0.0", "--github-output"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "GITHUB_OUTPUT" in result.output
|
||||||
|
|
||||||
|
def test_allow_empty_with_github_output(self, tmp_path: Path) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
gh_output = tmp_path / "github_output"
|
||||||
|
gh_output.write_text("")
|
||||||
|
with runner.isolation(env={"GITHUB_OUTPUT": str(gh_output)}):
|
||||||
|
result = runner.invoke(main, ["--allow-empty", "--github-output"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "deploy-ref=" in gh_output.read_text()
|
||||||
Reference in New Issue
Block a user