From 86ea6b9a3a52ffa53a81dafaf2b9161d63a83e49 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Sun, 2 Aug 2026 20:56:31 +0400 Subject: [PATCH 1/3] chore: bump rhiza to v1.3.0 --- .rhiza/template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.rhiza/template.yml b/.rhiza/template.yml index b1c3ba5d..bc929b15 100644 --- a/.rhiza/template.yml +++ b/.rhiza/template.yml @@ -1,5 +1,5 @@ repository: "jebel-quant/rhiza" -ref: "v1.2.5" +ref: "v1.3.0" profiles: - github-project From db7d99c413d0eea1a605023ec5c7ebdffc28ecb6 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Sun, 2 Aug 2026 20:57:36 +0400 Subject: [PATCH 2/3] chore: apply rhiza sync v1.3.0 --- .github/rulesets/main-branch-protection.json | 12 +- .github/workflows/rhiza_benchmark.yml | 2 +- .github/workflows/rhiza_book.yml | 2 +- .github/workflows/rhiza_ci.yml | 2 +- .github/workflows/rhiza_codeql.yml | 2 +- .github/workflows/rhiza_marimo.yml | 2 +- .github/workflows/rhiza_mutation.yml | 2 +- .github/workflows/rhiza_paper.yml | 2 +- .github/workflows/rhiza_release.yml | 42 +++++ .gitignore | 15 +- .pre-commit-config.yaml | 13 +- .rhiza/make.d/bootstrap.mk | 61 +------ .rhiza/make.d/python.mk | 112 +++++++++++++ .rhiza/make.d/quality.mk | 44 +---- .rhiza/rhiza.mk | 25 +-- .rhiza/template.lock | 8 +- .rhiza/tests/test_pyproject.py | 162 ++++++++++++++++++- 17 files changed, 373 insertions(+), 135 deletions(-) create mode 100644 .rhiza/make.d/python.mk diff --git a/.github/rulesets/main-branch-protection.json b/.github/rulesets/main-branch-protection.json index 3511390a..f0306479 100644 --- a/.github/rulesets/main-branch-protection.json +++ b/.github/rulesets/main-branch-protection.json @@ -27,12 +27,12 @@ "parameters": { "strict_required_status_checks_policy": false, "required_status_checks": [ - { "context": "Pre-commit hooks", "integration_id": 15368 }, - { "context": "Check dependencies with deptry", "integration_id": 15368 }, - { "context": "docs-coverage", "integration_id": 15368 }, - { "context": "Security scanning", "integration_id": 15368 }, - { "context": "License compliance scan", "integration_id": 15368 }, - { "context": "CI gate", "integration_id": 15368 } + { "context": "ci / Pre-commit hooks", "integration_id": 15368 }, + { "context": "ci / Check dependencies with deptry", "integration_id": 15368 }, + { "context": "ci / docs-coverage", "integration_id": 15368 }, + { "context": "ci / Security scanning", "integration_id": 15368 }, + { "context": "ci / License compliance scan", "integration_id": 15368 }, + { "context": "ci / CI gate", "integration_id": 15368 } ] } } diff --git a/.github/workflows/rhiza_benchmark.yml b/.github/workflows/rhiza_benchmark.yml index 234a8386..338682e4 100644 --- a/.github/workflows/rhiza_benchmark.yml +++ b/.github/workflows/rhiza_benchmark.yml @@ -20,5 +20,5 @@ on: jobs: benchmark: - uses: jebel-quant/rhiza/.github/workflows/rhiza_benchmark.yml@v1.2.5 + uses: jebel-quant/rhiza/.github/workflows/rhiza_benchmark.yml@v1.3.0 secrets: inherit diff --git a/.github/workflows/rhiza_book.yml b/.github/workflows/rhiza_book.yml index 8002ff5b..0c9ae502 100644 --- a/.github/workflows/rhiza_book.yml +++ b/.github/workflows/rhiza_book.yml @@ -29,7 +29,7 @@ on: jobs: book: - uses: jebel-quant/rhiza/.github/workflows/rhiza_book.yml@v1.2.5 + uses: jebel-quant/rhiza/.github/workflows/rhiza_book.yml@v1.3.0 secrets: inherit permissions: contents: read diff --git a/.github/workflows/rhiza_ci.yml b/.github/workflows/rhiza_ci.yml index e38fb3b5..4ac0eac4 100644 --- a/.github/workflows/rhiza_ci.yml +++ b/.github/workflows/rhiza_ci.yml @@ -26,5 +26,5 @@ on: jobs: ci: - uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v1.2.5 + uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v1.3.0 secrets: inherit diff --git a/.github/workflows/rhiza_codeql.yml b/.github/workflows/rhiza_codeql.yml index 4f4dcc6a..a856c2d0 100644 --- a/.github/workflows/rhiza_codeql.yml +++ b/.github/workflows/rhiza_codeql.yml @@ -39,7 +39,7 @@ on: jobs: codeql: - uses: jebel-quant/rhiza/.github/workflows/rhiza_codeql.yml@v1.2.5 + uses: jebel-quant/rhiza/.github/workflows/rhiza_codeql.yml@v1.3.0 secrets: inherit permissions: security-events: write # Upload CodeQL results to code scanning diff --git a/.github/workflows/rhiza_marimo.yml b/.github/workflows/rhiza_marimo.yml index 5c1d661b..a0e4fd2d 100644 --- a/.github/workflows/rhiza_marimo.yml +++ b/.github/workflows/rhiza_marimo.yml @@ -28,5 +28,5 @@ on: jobs: marimo: - uses: jebel-quant/rhiza/.github/workflows/rhiza_marimo.yml@v1.2.5 + uses: jebel-quant/rhiza/.github/workflows/rhiza_marimo.yml@v1.3.0 secrets: inherit diff --git a/.github/workflows/rhiza_mutation.yml b/.github/workflows/rhiza_mutation.yml index dacfd1f2..c9e87c57 100644 --- a/.github/workflows/rhiza_mutation.yml +++ b/.github/workflows/rhiza_mutation.yml @@ -42,7 +42,7 @@ jobs: # this repo sets the `MUTATION_ENABLED` variable to 'true'. Gating here in # the caller keeps it optional regardless of the pinned reusable workflow. if: ${{ vars.MUTATION_ENABLED == 'true' }} - uses: jebel-quant/rhiza/.github/workflows/rhiza_mutation.yml@v1.2.5 + uses: jebel-quant/rhiza/.github/workflows/rhiza_mutation.yml@v1.3.0 secrets: inherit permissions: contents: read diff --git a/.github/workflows/rhiza_paper.yml b/.github/workflows/rhiza_paper.yml index 8f87be3c..365b1384 100644 --- a/.github/workflows/rhiza_paper.yml +++ b/.github/workflows/rhiza_paper.yml @@ -28,7 +28,7 @@ on: jobs: paper: - uses: jebel-quant/rhiza/.github/workflows/rhiza_paper.yml@v1.2.5 + uses: jebel-quant/rhiza/.github/workflows/rhiza_paper.yml@v1.3.0 secrets: inherit permissions: contents: write diff --git a/.github/workflows/rhiza_release.yml b/.github/workflows/rhiza_release.yml index 6a2b2a39..136675dd 100644 --- a/.github/workflows/rhiza_release.yml +++ b/.github/workflows/rhiza_release.yml @@ -156,6 +156,48 @@ jobs: fi fi + - name: Ensure the tagged commit is reachable from a branch + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.set_tag.outputs.tag }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + # Backstop for issue #1454: a release cut on a branch that is then + # squash-merged leaves the tag on the pre-squash commit, and the squash + # puts the same content on the default branch under a new SHA. The tag is + # then permanently orphaned — no branch contains it, `git describe` skips + # the release, and a git-cliff regeneration silently deletes that version's + # CHANGELOG section because it cannot place a boundary at an unreachable + # tag. Publishing from such a tag is never intended, so refuse it. + # The checkout above uses fetch-depth: 0; this fetch only adds the remote + # branch refs, which a tag-push checkout does not need otherwise. + git fetch --no-tags --quiet origin '+refs/heads/*:refs/remotes/origin/*' + + if [ -z "$DEFAULT_BRANCH" ]; then + DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name') + fi + if ! git rev-parse --verify --quiet "refs/remotes/origin/$DEFAULT_BRANCH" >/dev/null; then + echo "::error::Cannot resolve the default branch 'origin/$DEFAULT_BRANCH' — refusing to release without a reachability check." + exit 1 + fi + + COMMIT=$(git rev-parse "$TAG^{commit}") + if git merge-base --is-ancestor "$COMMIT" "refs/remotes/origin/$DEFAULT_BRANCH"; then + echo "✅ $TAG ($COMMIT) is an ancestor of $DEFAULT_BRANCH" + exit 0 + fi + + # Reachable from some other branch: a maintenance/hotfix release. Legitimate, + # but a changelog regenerated from the default branch still cannot see it. + BRANCHES=$(git branch -r --contains "$COMMIT" --format='%(refname:short)') + if [ -n "$BRANCHES" ]; then + echo "::warning::Tag $TAG is not an ancestor of $DEFAULT_BRANCH; it is contained in: $(echo "$BRANCHES" | tr '\n' ' '). A CHANGELOG regenerated from $DEFAULT_BRANCH will not include this release." + exit 0 + fi + + echo "::error::Tag $TAG points at $COMMIT, which no branch contains. It is most likely a pre-squash commit from a squash-merged release branch: re-tag the merged commit on $DEFAULT_BRANCH and delete this tag (issue #1454)." + exit 1 + - name: Install uv uses: astral-sh/setup-uv@v7.6.0 diff --git a/.gitignore b/.gitignore index 7ddaacf9..597bc12e 100644 --- a/.gitignore +++ b/.gitignore @@ -121,12 +121,9 @@ local.mk .bandit-baseline.json -report.html -/demo_report.py -/positions.csv -/prices.csv -LICENSES.md -/test_output/ - -# local planning file -plan.md + +# Rust (rust-core bundle). Kept in core rather than the language layer because +# .gitignore has one owner and git opens it with O_NOFOLLOW, so it cannot be a +# per-layer file. These entries are inert in a Python repo. +target/ +**/*.rs.bk diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 133da54c..8e0f6724 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,7 +31,7 @@ repos: pass_filenames: false - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.16.0' + rev: 'v0.16.1' hooks: - id: ruff args: [ --fix, --exit-non-zero-on-fix, --unsafe-fixes ] @@ -71,12 +71,12 @@ repos: args: ["--ini", ".bandit", "--exclude", ".venv,tests,.rhiza/tests,.git,.pytest_cache"] - repo: https://github.com/betterleaks/betterleaks - rev: v1.7.2 + rev: v1.7.3 hooks: - id: betterleaks - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.12.0 + rev: 0.12.1 hooks: - id: uv-lock @@ -88,7 +88,7 @@ repos: files: ^src/ - repo: https://github.com/Jebel-Quant/rhiza-hooks - rev: v0.7.0 # Use the latest release + rev: v0.7.1 # Use the latest release hooks: # Migrated from rhiza - id: check-rhiza-workflow-names @@ -97,4 +97,9 @@ repos: - id: check-rhiza-config - id: check-makefile-targets - id: check-python-version-consistency + # check-bumpversion-config asserts that bump-my-version can actually discover + # this repo's version config (issue #1453). Enable it once rhiza-hooks ships a + # release containing it — v0.7.1 does not. Until then .rhiza/tests/test_pyproject.py + # enforces the same invariant, one gate later. + # - id: check-bumpversion-config # - id: check-template-bundles diff --git a/.rhiza/make.d/bootstrap.mk b/.rhiza/make.d/bootstrap.mk index 4780c027..9bf67e25 100644 --- a/.rhiza/make.d/bootstrap.mk +++ b/.rhiza/make.d/bootstrap.mk @@ -1,11 +1,13 @@ ## .rhiza/make.d/bootstrap.mk - Bootstrap and Installation -# This file provides targets for setting up the development environment, -# installing dependencies, and cleaning project artifacts. +# This file provides the language-neutral half of setup: it puts uv/uvx on the +# path (rhiza runs pre-commit, mkdocs and semgrep through uvx whatever the +# project is written in), declares the install hooks, and cleans artifacts. +# +# `install` itself belongs to the language layer, because what it means differs: +# python.mk (bundle python-core) creates a virtualenv and runs `uv sync`. # Declare phony targets (they don't produce files) -.PHONY: install-uv install clean pre-install post-install - -UV_SYNC_ARGS ?= --all-extras --all-groups +.PHONY: install-uv clean pre-install post-install # Hook targets (double-colon rules allow multiple definitions) pre-install:: ; @: @@ -29,55 +31,6 @@ install-uv: ## ensure uv/uvx is installed fi; \ fi -install: pre-install install-uv ## install - # Create the virtual environment only if it doesn't exist - @if [ ! -d "${VENV}" ]; then \ - ${UV_BIN} venv $(if $(PYTHON_VERSION),--python $(PYTHON_VERSION)) ${VENV} || { printf "${RED}[ERROR] Failed to create virtual environment${RESET}\n"; exit 1; }; \ - else \ - printf "${BLUE}[INFO] Using existing virtual environment at ${VENV}, skipping creation${RESET}\n"; \ - fi - - # Install the dependencies from pyproject.toml (if it exists). - # --inexact leaves packages uv did not manage in place instead of pruning them each - # run, so repeated 'make' targets don't churn the environment. Per-target tooling - # (pytest, interrogate, mutmut, ...) is provisioned on the fly via `uv run --with` - # in the individual targets, so there is no separate dependency-install step here. - @if [ -f "pyproject.toml" ]; then \ - if [ -f "uv.lock" ]; then \ - if ! ${UV_BIN} lock --check >/dev/null 2>&1; then \ - printf "${YELLOW}[WARN] uv.lock is out of sync with pyproject.toml${RESET}\n"; \ - printf "${YELLOW} Run 'uv sync' to update your lock file and environment${RESET}\n"; \ - printf "${YELLOW} Or run 'uv lock' to update only the lock file${RESET}\n"; \ - exit 1; \ - fi; \ - printf "${BLUE}[INFO] Installing dependencies from lock file${RESET}\n"; \ - ${UV_BIN} sync $(UV_SYNC_ARGS) --inexact --frozen || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ - else \ - printf "${YELLOW}[WARN] uv.lock not found. Generating lock file and installing dependencies...${RESET}\n"; \ - ${UV_BIN} sync $(UV_SYNC_ARGS) --inexact || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ - fi; \ - else \ - printf "${YELLOW}[WARN] No pyproject.toml found, skipping install${RESET}\n"; \ - fi - - # Install pre-commit hooks (skip when core.hooksPath is set, e.g. by an - # external hook manager — pre-commit refuses to install in that case) - @if [ -f ".pre-commit-config.yaml" ]; then \ - if [ -n "$$(git config --get core.hooksPath 2>/dev/null)" ]; then \ - printf "${BLUE}[INFO] Skipping pre-commit hook install: core.hooksPath is set${RESET}\n"; \ - else \ - printf "${BLUE}[INFO] Installing pre-commit hooks...${RESET}\n"; \ - ${UVX_BIN} -p ${PYTHON_VERSION} pre-commit install || { printf "${YELLOW}[WARN] Failed to install pre-commit hooks${RESET}\n"; }; \ - fi; \ - fi - - @$(MAKE) post-install - - # Display success message with activation instructions - @printf "\n${GREEN}[SUCCESS] Installation complete!${RESET}\n\n" - @printf "${BLUE}To activate the virtual environment, run:${RESET}\n" - @printf "${YELLOW} source ${VENV}/bin/activate${RESET}\n\n" - clean: ## Clean project artifacts and stale local branches @printf "%bCleaning project...%b\n" "$(BLUE)" "$(RESET)" diff --git a/.rhiza/make.d/python.mk b/.rhiza/make.d/python.mk new file mode 100644 index 00000000..65b70035 --- /dev/null +++ b/.rhiza/make.d/python.mk @@ -0,0 +1,112 @@ +## .rhiza/make.d/python.mk - the Python language layer (bundle: python-core) +# +# Everything in rhiza that only makes sense because the project is written in +# Python. `core` provides the make framework and uv/uvx as a tool runner; this +# file turns that into a Python project: the virtualenv, the `install` that syncs +# it, dependency and licence analysis of the declared dependencies, and the `all` +# aggregate naming the gates. +# +# A sibling language layer (rust.mk, from a rust-core bundle) ships the same +# *target names* — install, all — with different recipes. That contract is the +# reason book.mk, test.mk and the CI workflows can call `make install` without +# knowing the language. Only one language layer is ever synced into a repo. + +# Declare phony targets (they don't produce files) +.PHONY: all deptry install license rhiza-test + +# The project virtualenv, and the interpreter that fills it. PYTHON_VERSION is +# declared in rhiza.mk (core needs a Python to run its own tooling on); here +# `.python-version` — which this bundle ships — makes it the project's version too. +VENV ?= .venv +UV_SYNC_ARGS ?= --all-extras --all-groups + +export UV_VENV_CLEAR := 1 + +# Configurable list of licenses that fail the compliance scan (semicolon-separated) +LICENSE_FAIL_ON ?= GPL;LGPL;AGPL + +##@ Python +install: pre-install install-uv ## install + # Create the virtual environment only if it doesn't exist + @if [ ! -d "${VENV}" ]; then \ + ${UV_BIN} venv $(if $(PYTHON_VERSION),--python $(PYTHON_VERSION)) ${VENV} || { printf "${RED}[ERROR] Failed to create virtual environment${RESET}\n"; exit 1; }; \ + else \ + printf "${BLUE}[INFO] Using existing virtual environment at ${VENV}, skipping creation${RESET}\n"; \ + fi + + # Install the dependencies from pyproject.toml (if it exists). + # --inexact leaves packages uv did not manage in place instead of pruning them each + # run, so repeated 'make' targets don't churn the environment. Per-target tooling + # (pytest, interrogate, mutmut, ...) is provisioned on the fly via `uv run --with` + # in the individual targets, so there is no separate dependency-install step here. + @if [ -f "pyproject.toml" ]; then \ + if [ -f "uv.lock" ]; then \ + if ! ${UV_BIN} lock --check >/dev/null 2>&1; then \ + printf "${YELLOW}[WARN] uv.lock is out of sync with pyproject.toml${RESET}\n"; \ + printf "${YELLOW} Run 'uv sync' to update your lock file and environment${RESET}\n"; \ + printf "${YELLOW} Or run 'uv lock' to update only the lock file${RESET}\n"; \ + exit 1; \ + fi; \ + printf "${BLUE}[INFO] Installing dependencies from lock file${RESET}\n"; \ + ${UV_BIN} sync $(UV_SYNC_ARGS) --inexact --frozen || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ + else \ + printf "${YELLOW}[WARN] uv.lock not found. Generating lock file and installing dependencies...${RESET}\n"; \ + ${UV_BIN} sync $(UV_SYNC_ARGS) --inexact || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ + fi; \ + else \ + printf "${YELLOW}[WARN] No pyproject.toml found, skipping install${RESET}\n"; \ + fi + + # Install pre-commit hooks (skip when core.hooksPath is set, e.g. by an + # external hook manager — pre-commit refuses to install in that case) + @if [ -f ".pre-commit-config.yaml" ]; then \ + if [ -n "$$(git config --get core.hooksPath 2>/dev/null)" ]; then \ + printf "${BLUE}[INFO] Skipping pre-commit hook install: core.hooksPath is set${RESET}\n"; \ + else \ + printf "${BLUE}[INFO] Installing pre-commit hooks...${RESET}\n"; \ + ${UVX_BIN} -p ${PYTHON_VERSION} pre-commit install || { printf "${YELLOW}[WARN] Failed to install pre-commit hooks${RESET}\n"; }; \ + fi; \ + fi + + @$(MAKE) post-install + + # Display success message with activation instructions + @printf "\n${GREEN}[SUCCESS] Installation complete!${RESET}\n\n" + @printf "${BLUE}To activate the virtual environment, run:${RESET}\n" + @printf "${YELLOW} source ${VENV}/bin/activate${RESET}\n\n" + +all: fmt deptry test docs-coverage security license typecheck rhiza-test ## run all CI targets locally + +# deptry scans one or more folders for dependency issues. Each feature bundle +# contributes the folders it owns to DEPTRY_FOLDERS (and any per-folder ignores +# to DEPTRY_IGNORE), so this target never needs to know which bundles are +# present. The language layer itself contributes SOURCE_FOLDER when it exists; see e.g. +# marimo.mk for a bundle that appends its own folder. Rhiza's own test folder +# (.rhiza/tests) is deliberately excluded: its tooling is provisioned on the fly +# via `uv run --with` in the individual targets, not declared in the project's +# pyproject, so deptry (which validates against pyproject) would only emit noise +# for it. +DEPTRY_FOLDERS ?= +DEPTRY_IGNORE ?= +ifneq ($(wildcard $(SOURCE_FOLDER)),) +DEPTRY_FOLDERS += $(SOURCE_FOLDER) +endif + +deptry: install-uv ## Run deptry over the folders contributed by each bundle + @if [ -n "$(strip $(DEPTRY_FOLDERS))" ]; then \ + printf "${BLUE}[INFO] Running deptry on:${RESET} $(strip $(DEPTRY_FOLDERS))\n"; \ + $(UVX_BIN) -p ${PYTHON_VERSION} deptry $(strip $(DEPTRY_FOLDERS) $(DEPTRY_IGNORE)); \ + else \ + printf "${YELLOW}[WARN] no deptry folders found, skipping.${RESET}\n"; \ + fi + +license: install ## run license compliance scan (fail on GPL, LGPL, AGPL) + @printf "${BLUE}[INFO] Running license compliance scan...${RESET}\n" + @${UV_BIN} run --with pip-licenses pip-licenses --fail-on="${LICENSE_FAIL_ON}" + +rhiza-test: install ## run rhiza's own tests (if any) + @if [ -d ".rhiza/tests" ]; then \ + ${UV_BIN} run --with pytest --with pytest-timeout --with python-dotenv --with packaging pytest .rhiza/tests; \ + else \ + printf "${YELLOW}[WARN] No .rhiza/tests directory found, skipping rhiza-tests${RESET}\n"; \ + fi diff --git a/.rhiza/make.d/quality.mk b/.rhiza/make.d/quality.mk index 8449f905..e1b6ee39 100644 --- a/.rhiza/make.d/quality.mk +++ b/.rhiza/make.d/quality.mk @@ -1,38 +1,13 @@ ## .rhiza/make.d/quality.mk - Quality and Formatting -# This file provides targets for code quality checks, linting, and formatting. - -# Configurable list of licenses that fail the compliance scan (semicolon-separated) -LICENSE_FAIL_ON ?= GPL;LGPL;AGPL +# The language-neutral gates: pre-commit, the TODO sweep, and semgrep. Everything +# that needs to know how the project declares its dependencies — `deptry`, the +# licence-compliance scan — and the `all` aggregate that names the per-language +# gates live in the language layer (python.mk, from the python-core bundle). # Declare phony targets (they don't produce files) -.PHONY: all deptry fmt license todos semgrep +.PHONY: fmt todos semgrep ##@ Quality and Formatting -all: fmt deptry test docs-coverage security license typecheck rhiza-test ## run all CI targets locally - -# deptry scans one or more folders for dependency issues. Each feature bundle -# contributes the folders it owns to DEPTRY_FOLDERS (and any per-folder ignores -# to DEPTRY_IGNORE), so this core target never needs to know which bundles are -# present. Core itself contributes SOURCE_FOLDER when it exists; see e.g. -# marimo.mk for a bundle that appends its own folder. Rhiza's own test folder -# (.rhiza/tests) is deliberately excluded: its tooling is provisioned on the fly -# via `uv run --with` in the individual targets, not declared in the project's -# pyproject, so deptry (which validates against pyproject) would only emit noise -# for it. -DEPTRY_FOLDERS ?= -DEPTRY_IGNORE ?= -ifneq ($(wildcard $(SOURCE_FOLDER)),) -DEPTRY_FOLDERS += $(SOURCE_FOLDER) -endif - -deptry: install-uv ## Run deptry over the folders contributed by each bundle - @if [ -n "$(strip $(DEPTRY_FOLDERS))" ]; then \ - printf "${BLUE}[INFO] Running deptry on:${RESET} $(strip $(DEPTRY_FOLDERS))\n"; \ - $(UVX_BIN) -p ${PYTHON_VERSION} deptry $(strip $(DEPTRY_FOLDERS) $(DEPTRY_IGNORE)); \ - else \ - printf "${YELLOW}[WARN] no deptry folders found, skipping.${RESET}\n"; \ - fi - fmt: install-uv ## check the pre-commit hooks and the linting @${UVX_BIN} -p ${PYTHON_VERSION} pre-commit run --all-files @@ -59,12 +34,3 @@ semgrep: install ## run Semgrep static analysis else \ printf "${YELLOW}[WARN] SOURCE_FOLDER '${SOURCE_FOLDER}' not found, skipping semgrep.${RESET}\n"; \ fi - -# Hook target: extend license behaviour without overriding the target. -# Double-colon rules allow multiple definitions — add post-license:: in your Makefile. -post-license:: ; @: - -license: install ## run license compliance scan (fail on GPL, LGPL, AGPL) - @printf "${BLUE}[INFO] Running license compliance scan...${RESET}\n" - @${UV_BIN} run --with pip-licenses pip-licenses --fail-on="${LICENSE_FAIL_ON}" - @$(MAKE) post-license diff --git a/.rhiza/rhiza.mk b/.rhiza/rhiza.mk index 7d981d83..6d27a3ff 100644 --- a/.rhiza/rhiza.mk +++ b/.rhiza/rhiza.mk @@ -62,18 +62,28 @@ RESET := \033[0m version-matrix \ ci-os-matrix +# --------------------------------------------------------------------------- +# uv as a *tool runner*, not as a language choice. +# +# Rhiza reaches for uvx to run pre-commit, mkdocs, semgrep and the rest whatever +# the project is written in, so provisioning it belongs to core. What is Python- +# specific — the project virtualenv, the `install` target that syncs it, and the +# project's own interpreter version — lives in the language layer +# (`bundles/python-core`), which ships `.rhiza/make.d/python.mk`. +# --------------------------------------------------------------------------- + # we need absolute paths! INSTALL_DIR ?= $(abspath ./bin) UV_BIN ?= $(shell command -v uv 2>/dev/null || echo ${INSTALL_DIR}/uv) UVX_BIN ?= $(shell command -v uvx 2>/dev/null || echo ${INSTALL_DIR}/uvx) -VENV ?= .venv -# Read Python version from .python-version (single source of truth) +# The interpreter rhiza's own tooling runs on. A Python project overrides this +# from `.python-version` (see python.mk, which makes it the project's version +# too); anywhere else the fallback is simply the Python uvx provisions. PYTHON_VERSION ?= $(strip $(shell cat .python-version 2>/dev/null || echo "3.13")) export PYTHON_VERSION export UV_NO_MODIFY_PATH := 1 -export UV_VENV_CLEAR := 1 # Unset VIRTUAL_ENV to prevent uv from warning about path mismatches # when a virtual environment is already activated in the shell @@ -123,20 +133,13 @@ print-logo: @printf "${BLUE}$$RHIZA_LOGO${RESET}\n" -rhiza-test: install ## run rhiza's own tests (if any) - @if [ -d ".rhiza/tests" ]; then \ - ${UV_BIN} run --with pytest --with pytest-timeout --with python-dotenv --with packaging pytest .rhiza/tests; \ - else \ - printf "${YELLOW}[WARN] No .rhiza/tests directory found, skipping rhiza-tests${RESET}\n"; \ - fi - ##@ Meta help: print-logo ## Display this help message +@printf "$(BOLD)Usage:$(RESET)\n" +@printf " make $(BLUE)$(RESET)\n\n" +@printf "$(BOLD)Targets:$(RESET)\n" - +@awk 'BEGIN {FS = ":.*##"; printf ""} /^[a-zA-Z_-]+:.*?##/ { printf " $(BLUE)%-20s$(RESET) %s\n", $$1, $$2 } /^##@/ { printf "\n$(BOLD)%s$(RESET)\n", substr($$0, 5) }' $(MAKEFILE_LIST) + +@awk 'BEGIN {FS = ":.*##"; printf ""} /^[a-zA-Z0-9_-]+:.*?##/ { printf " $(BLUE)%-20s$(RESET) %s\n", $$1, $$2 } /^##@/ { printf "\n$(BOLD)%s$(RESET)\n", substr($$0, 5) }' $(MAKEFILE_LIST) +@printf "\n" ci-os-matrix: ## Emit GitHub CI OSes (RHIZA_CI_OS_MATRIX as JSON array, default ["ubuntu-latest"]) diff --git a/.rhiza/template.lock b/.rhiza/template.lock index 26eaf961..8007311e 100644 --- a/.rhiza/template.lock +++ b/.rhiza/template.lock @@ -1,7 +1,7 @@ -sha: e5d7005e2d9fc3d5a23b96d13b203b4f2afe3cce +sha: 65f3c209dcaafc6dbc7763eb0d022125379292c8 repo: jebel-quant/rhiza host: github -ref: v1.2.5 +ref: v1.3.0 include: [] exclude: - bundles/github/.github/workflows/rhiza_fuzzing.yml @@ -38,7 +38,6 @@ files: - .gitignore - .pre-commit-config.yaml - .python-version -- .rhiza/.cfg.toml - .rhiza/.env - .rhiza/.gitignore - .rhiza/CODE_OF_CONDUCT.md @@ -56,6 +55,7 @@ files: - .rhiza/make.d/github.mk - .rhiza/make.d/marimo.mk - .rhiza/make.d/paper.mk +- .rhiza/make.d/python.mk - .rhiza/make.d/quality.mk - .rhiza/make.d/test.mk - .rhiza/rhiza.mk @@ -76,5 +76,5 @@ files: - docs/mkdocs-base.yml - pytest.ini - ruff.toml -synced_at: '2026-07-30T04:16:36Z' +synced_at: '2026-08-02T16:56:56Z' strategy: merge diff --git a/.rhiza/tests/test_pyproject.py b/.rhiza/tests/test_pyproject.py index af0d2492..58e9ed87 100644 --- a/.rhiza/tests/test_pyproject.py +++ b/.rhiza/tests/test_pyproject.py @@ -13,7 +13,8 @@ - includes at least one Python version classifier - declares a [dependency-groups] test group containing pytest - declares a [dependency-groups] lint group -- version matches the latest git tag (vX.Y.Z → X.Y.Z) +- carries a [tool.bumpversion] table bump-my-version can actually discover +- version matches the latest git tag (vX.Y.Z → X.Y.Z), and that tag is reachable """ from __future__ import annotations @@ -32,6 +33,34 @@ _SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+") _REQUIRED_PROJECT_FIELDS = ("name", "version", "description", "readme", "requires-python", "license", "authors") +# The only filenames bump-my-version auto-discovers. Anything else — including the +# `.rhiza/.cfg.toml` older template versions shipped — is read solely when passed +# with --config-file, which nothing in this template does. +_DISCOVERABLE_CONFIGS = (".bumpversion.toml", ".bumpversion.cfg", "setup.cfg", "pyproject.toml") + + +def _has_bumpversion_section(path: Path) -> bool: + """Report whether a config file carries a bumpversion section at all. + + Args: + path: Candidate config file; a missing or malformed file counts as absent. + + Returns: + True when the file declares ``[tool.bumpversion]`` (TOML) or ``[bumpversion]`` + (INI). ``.bumpversion.toml`` nests the table under ``[tool]`` just as + pyproject.toml does. + """ + if not path.is_file(): + return False + if path.suffix == ".cfg": + return "[bumpversion]" in path.read_text(encoding="utf-8") + try: + with path.open("rb") as handle: + data = tomllib.load(handle) + except tomllib.TOMLDecodeError: + return False + return isinstance(data.get("tool", {}).get("bumpversion"), dict) + @pytest.fixture(scope="module") def pyproject(root: Path) -> dict: @@ -197,6 +226,98 @@ def test_lint_group_present(self, dependency_groups: dict) -> None: assert "lint" in dependency_groups, "[dependency-groups] must include a 'lint' group" +class TestBumpversionConfigIsDiscoverable: + """The release flow must find a version config, not silently invent one (#1453). + + bump-my-version searches four filenames and stops. When it finds none it does + **not** fail — it falls back to ``git describe`` and reports the last reachable + tag as the current version. Release tooling then computes bump candidates from + that number rather than the project's, which is how a repo at 0.7.0 with a + newest reachable tag of v0.6.4 gets offered "minor → v0.7.0", a version it has + already published. + + Once a ``[tool.bumpversion]`` table exists in pyproject.toml, bump-my-version + reads and rewrites PEP 621 ``[project].version`` natively, so the minimum + workable config is three lines and duplicates the version string nowhere:: + + [tool.bumpversion] + allow_dirty = false + # /rhiza:release commits and tags itself so the changelog lands in the + # bump commit. + commit = false + tag = false + + Add a ``[[tool.bumpversion.files]]`` entry per *additional* location (a plugin + manifest, a self-referencing CI stub pin) — never for ``[project].version`` + itself. + """ + + @pytest.fixture + def declared_version(self, project: dict) -> str: + """The statically declared project version, or skip when it is dynamic.""" + version = project.get("version") + if not isinstance(version, str): + pytest.skip("[project].version is dynamic — no static location to bump") + return version + + def test_a_discoverable_config_exists(self, root: Path, pyproject: dict, declared_version: str) -> None: + """A bumpversion section must live in a file bump-my-version actually reads.""" + found = [name for name in _DISCOVERABLE_CONFIGS if _has_bumpversion_section(root / name)] + hint = "" + if (root / ".rhiza" / ".cfg.toml").is_file(): + hint = ( + " A leftover .rhiza/.cfg.toml is present: that path is never auto-discovered " + "(it predates the fix for issue #1453) and can be deleted." + ) + assert found, ( + f"pyproject.toml declares version {declared_version!r} but no bumpversion config " + f"was found in any file bump-my-version searches ({', '.join(_DISCOVERABLE_CONFIGS)}). " + f"It will silently fall back to `git describe`, so a release can be cut at a version " + f"that already exists. Add a [tool.bumpversion] table to pyproject.toml.{hint}" + ) + + def test_pyproject_is_the_config_that_wins(self, root: Path, declared_version: str) -> None: + """No earlier-searched file may shadow pyproject.toml's table. + + Search order is significant: a ``.bumpversion.toml`` beats pyproject.toml and + takes ``[project].version`` out of the picture, so the two version numbers can + then drift apart unnoticed. A Python project keeps its version in one place. + """ + shadowing = [ + name for name in _DISCOVERABLE_CONFIGS if name != "pyproject.toml" and _has_bumpversion_section(root / name) + ] + assert not shadowing, ( + f"{shadowing} is searched before pyproject.toml and would shadow its " + f"[tool.bumpversion] table, detaching the bump from [project].version " + f"({declared_version!r})" + ) + + def test_config_does_not_duplicate_the_version(self, pyproject: dict, declared_version: str) -> None: + """``current_version`` is redundant in pyproject.toml, and drifts once stale.""" + section = pyproject.get("tool", {}).get("bumpversion") + if not isinstance(section, dict): + pytest.skip("no [tool.bumpversion] table — reported by test_a_discoverable_config_exists") + declared_in_config = section.get("current_version") + assert declared_in_config in (None, declared_version), ( + f"[tool.bumpversion].current_version is {declared_in_config!r} but " + f"[project].version is {declared_version!r}; bumping from the stale value cannot " + f"match the version in the file. Drop current_version — bump-my-version reads " + f"[project].version natively." + ) + + def test_the_release_flow_owns_the_commit_and_the_tag(self, pyproject: dict) -> None: + """``/rhiza:release`` folds the changelog into the bump commit and tags it itself.""" + section = pyproject.get("tool", {}).get("bumpversion") + if not isinstance(section, dict): + pytest.skip("no [tool.bumpversion] table — reported by test_a_discoverable_config_exists") + for key in ("commit", "tag"): + assert section.get(key, False) is False, ( + f"[tool.bumpversion].{key} must be false: the release flow commits and tags " + f"itself so the changelog lands in the bump commit, and a bare " + f"`bump-my-version bump` would otherwise add a second commit and a duplicate tag" + ) + + class TestGitTagVersion: """Tests for harmony between the latest git tag and pyproject.toml version.""" @@ -222,3 +343,42 @@ def test_latest_tag_matches_pyproject_version(self, latest_tag: str, project: di f"Latest git tag {latest_tag!r} (→ {tag_version!r}) does not match " f"[project].version {pyproject_version!r} in pyproject.toml" ) + + def test_latest_tag_is_reachable_from_a_branch(self, latest_tag: str, root: Path) -> None: + """The newest tag must sit on a commit some branch contains (#1454). + + ``git tag --list`` above happily reports an orphaned tag, which is how this + suite once stayed green on a repo where ``git describe`` disagreed. A release + cut on a branch that is then squash-merged leaves its tag on the pre-squash + commit while the content lands on the default branch under a new SHA; no + branch contains the tagged commit any more. The consequence is not cosmetic — + git-cliff cannot place a boundary at an unreachable tag, so regenerating + CHANGELOG.md deletes that version's section and folds its commits into the + next release. + """ + if ( + subprocess.run( # nosec B603 + [_GIT, "rev-parse", "--is-shallow-repository"], capture_output=True, text=True, cwd=root + ).stdout.strip() + == "true" + ): + pytest.skip("shallow clone — the commit graph is incomplete") + + commit = subprocess.run( # nosec B603 + [_GIT, "rev-parse", f"{latest_tag}^{{commit}}"], capture_output=True, text=True, cwd=root + ) + if commit.returncode != 0: + pytest.skip(f"tagged commit for {latest_tag} is not present locally") + + contains = subprocess.run( # nosec B603 + [_GIT, "branch", "-a", "--contains", commit.stdout.strip(), "--format=%(refname:short)"], + capture_output=True, + text=True, + cwd=root, + ) + assert contains.stdout.strip(), ( + f"Tag {latest_tag} points at {commit.stdout.strip()[:12]}, which no branch contains. " + f"It is most likely the pre-squash commit of a squash-merged release branch: " + f"`git describe` skips this release and regenerating CHANGELOG.md will delete its " + f"section. Re-tag the merged commit and delete the orphaned tag." + ) From 0ba39c8f3450d0588abbf3876a97321146085b9b Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Sun, 2 Aug 2026 21:00:23 +0400 Subject: [PATCH 3/3] chore: drop .rhiza/.cfg.toml, no longer template-owned in v1.3.0 --- .rhiza/.cfg.toml | 62 ------------------------------------------------ 1 file changed, 62 deletions(-) delete mode 100644 .rhiza/.cfg.toml diff --git a/.rhiza/.cfg.toml b/.rhiza/.cfg.toml deleted file mode 100644 index 856b363c..00000000 --- a/.rhiza/.cfg.toml +++ /dev/null @@ -1,62 +0,0 @@ -[tool.bumpversion] -parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(?:[-]?(?P[a-z]+)[\\.]?(?P\\d+))?(?:\\+build\\.(?P\\d+))?" -serialize = ["{major}.{minor}.{patch}-{release}.{pre_n}+build.{build_n}", "{major}.{minor}.{patch}+build.{build_n}", "{major}.{minor}.{patch}-{release}.{pre_n}", "{major}.{minor}.{patch}"] -search = "{current_version}" -replace = "{new_version}" -regex = false -ignore_missing_version = false -ignore_missing_files = false -tag = true -sign_tags = false -tag_name = "v{new_version}" -tag_message = "Bump version: {current_version} → {new_version}" -allow_dirty = false -commit = true -message = "Chore: bump version {current_version} → {new_version}" -commit_args = "" -pre_commit_hooks = ["uv sync", "git add uv.lock"] # Ensure uv.lock is updated - -[tool.bumpversion.parts.release] -optional_value = "prod" -values = [ - "dev", - "alpha", - "a", # PEP 440 short form for alpha - "beta", - "b", # PEP 440 short form for beta - "rc", - "prod" -] - -# Anchored to the [project] table on purpose. `search`/`replace` are applied to -# EVERY occurrence in the file, so the obvious `version = "{current_version}"` -# also rewrites any [tool.*] table that happens to share the current number. -# Dependency pins (httpx>=1.2.0, rich==1.2.0) are never at risk either way — -# they are not line-anchored `version = ` assignments — but a second table is. -[[tool.bumpversion.files]] -filename = "pyproject.toml" -regex = true -search = '(?ms)^\[project\]((?:(?!^\[)[\s\S])*?)^version = "{current_version}"' -replace = '[project]\1version = "{new_version}"' - -# NOTE for downstream projects: do NOT add a glob over your own -# .github/workflows/*.yml here. Those stubs pin jebel-quant/rhiza's version -# (the template you sync from), not your project's, so rewriting them with your -# version would point them at a rhiza tag that does not exist. The template ref -# is managed by /rhiza:update via .rhiza/template.yml instead. - -# Keep the reusable-workflow stubs in the bundles pinned to the current release. -# These stubs are synced into downstream repos and must reference an existing tag. -# The search is a regex anchored to the `jebel-quant/rhiza/...` path so it rewrites -# the pin from ANY prior version (not just {current_version}) — this prevents silent -# drift if a stub was left behind — while leaving third-party action pins -# (actions/checkout@vX, astral-sh/setup-uv@vX, ...) in the same files untouched. -# This block ships verbatim in the synced downstream .cfg.toml; there it is a -# harmless no-op because a downstream repo has no bundles/ tree (the glob matches -# nothing and bump-my-version skips it). -[[tool.bumpversion.files]] -glob = "bundles/**/.github/workflows/*.yml" -regex = true -search = "(jebel-quant/rhiza/[^@\\s]+)@v\\d+\\.\\d+\\.\\d+" -replace = "\\1@v{new_version}" -ignore_missing_version = true