Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bee730a52f | ||
|
|
d0a4a774a0 | ||
|
|
882f9805ed | ||
|
|
a85e0baaea | ||
|
|
663572768b | ||
|
|
1f2533872d | ||
|
|
cb7e9dbc7e | ||
|
|
f5081e10b1 | ||
|
|
cabc0d1adc | ||
|
|
7c1ecd6ff9 | ||
|
|
5063f659bc | ||
|
|
96c77a0ba4 | ||
|
|
40dd578d89 | ||
|
|
7ea9b4a96b | ||
|
|
08ceaf484f | ||
|
|
fb6b0fda1d |
@@ -1,13 +1,13 @@
|
||||
name: Post-merge
|
||||
|
||||
# Runs on every push to master. A single workflow with conditional jobs
|
||||
# replaces separate workflows for release, wiki sync, badges, and
|
||||
# Vikunja task updates.
|
||||
# for release, publish, wiki sync, badges, and Vikunja task updates.
|
||||
#
|
||||
# Job dependency graph:
|
||||
#
|
||||
# detect-type ──┬── validate-commit-msg (skip if release commit)
|
||||
# ├── release (skip if release commit)
|
||||
# │ └── publish (needs release — builds & publishes to PyPI)
|
||||
# ├── badges (ALWAYS runs — even on release commits)
|
||||
# ├── configure-repo (independent — skip if release commit)
|
||||
# ├── sync-wiki (skip if release commit — runs for ALL merges)
|
||||
@@ -21,9 +21,10 @@ name: Post-merge
|
||||
# runs on every push to master, including release commits. This ensures
|
||||
# badges (tests, coverage, version, etc.) are always current.
|
||||
#
|
||||
# When release creates a "release: vX.Y.Z" commit, the release
|
||||
# commit's post-merge run still updates badges (version badge picks
|
||||
# up the new version). Other jobs skip. The tag push triggers publish.yml.
|
||||
# When release creates a "release: vX.Y.Z" commit and tag, the publish
|
||||
# job (which depends on release) builds and publishes the package to the
|
||||
# Gitea PyPI registry. The release commit's post-merge run still updates
|
||||
# badges (version badge picks up the new version). Other jobs skip.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -74,6 +75,8 @@ jobs:
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
tag: ${{ steps.release-tag.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -88,12 +91,20 @@ jobs:
|
||||
git config user.name "devx-ci-bot"
|
||||
git config user.email "devx-ci-bot@oblachno.fyi"
|
||||
- name: Run release
|
||||
id: release-tag
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.release
|
||||
- name: Extract tag (fallback if GITHUB_OUTPUT not set)
|
||||
if: steps.release-tag.outputs.tag == ''
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0 2>/dev/null || true)
|
||||
if [ -n "$tag" ]; then
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
@@ -108,6 +119,41 @@ jobs:
|
||||
--workflow "post-merge/release" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
publish:
|
||||
needs: [release]
|
||||
if: needs.release.outputs.tag != ''
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-release
|
||||
- name: Build and publish release
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_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 }}"
|
||||
|
||||
sync-wiki:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Publish Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to publish (e.g. v0.9.11)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-release
|
||||
- name: Build and publish release
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.publish "${{ github.event.inputs.tag || github.ref_name }}" "${{ github.repository }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_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 "publish" \
|
||||
--commit "${{ github.sha }}"
|
||||
@@ -204,8 +204,9 @@ After a PR is merged to master, the **post-merge workflow**
|
||||
non-release commits (not just when release succeeds), so infrastructure-only
|
||||
changes still update the task tracker.
|
||||
|
||||
The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`)
|
||||
which builds and publishes the package to the Gitea PyPI registry.
|
||||
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
|
||||
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.19.3] - 2026-06-26
|
||||
|
||||
### Refactor
|
||||
|
||||
- Make molecule weights configurable via pyproject.toml
|
||||
|
||||
## [0.19.2] - 2026-06-26
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Calibrate molecule weights from actual CI execution times
|
||||
|
||||
## [0.19.1] - 2026-06-26
|
||||
|
||||
### Refactor
|
||||
|
||||
- Consolidate publish.yml into post-merge.yml
|
||||
|
||||
## [0.19.0] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
- Add skip_ref_prefixes config to check_agent_docs
|
||||
|
||||
## [0.18.0] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](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/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/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](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/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
|
||||
+6
-6
@@ -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/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/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](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/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.18.0"
|
||||
__version__ = "0.19.3"
|
||||
|
||||
@@ -317,6 +317,21 @@ def run_tests() -> None:
|
||||
click.echo(_("Tests passed."))
|
||||
|
||||
|
||||
def _write_github_output(tag: str) -> None:
|
||||
"""Write the release tag to GITHUB_OUTPUT for downstream jobs.
|
||||
|
||||
This allows a publish job (needs: release) to read the tag via
|
||||
``${{ needs.release.outputs.tag }}`` instead of relying on
|
||||
tag-push event triggering a separate workflow.
|
||||
"""
|
||||
github_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not github_output:
|
||||
return
|
||||
with open(github_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"tag={tag}\n")
|
||||
click.echo(_("Wrote tag {tag} to GITHUB_OUTPUT.", tag=tag))
|
||||
|
||||
|
||||
def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool:
|
||||
"""Create an annotated tag with the changelog as message and push it.
|
||||
|
||||
@@ -345,6 +360,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
if not dry_run:
|
||||
# Ensure the existing tag is pushed
|
||||
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
|
||||
_write_github_output(tag)
|
||||
return False
|
||||
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
||||
if dry_run:
|
||||
@@ -352,6 +368,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
return True
|
||||
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
||||
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
|
||||
_write_github_output(tag)
|
||||
return True
|
||||
|
||||
|
||||
@@ -617,6 +634,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
tag=release_tag,
|
||||
)
|
||||
)
|
||||
_write_github_output(release_tag)
|
||||
return
|
||||
# Tag is missing — recover by creating and pushing it
|
||||
click.echo(
|
||||
|
||||
@@ -19,6 +19,7 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -132,27 +133,82 @@ def build_multi_role_pairs(
|
||||
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
|
||||
|
||||
|
||||
# Heuristic weights for known heavy molecule scenarios.
|
||||
# These are estimated from CI run times — scenarios that pull large Docker
|
||||
# images or run complex Ansible playbooks take longer.
|
||||
_SCENARIO_WEIGHTS: dict[str, int] = {
|
||||
"nextcloud": 10,
|
||||
"gitea": 8,
|
||||
"vaultwarden": 7,
|
||||
"zitadel": 7,
|
||||
"postgresql": 6,
|
||||
"redis": 5,
|
||||
"backup": 5,
|
||||
"docker-base": 4,
|
||||
"default": 3,
|
||||
"binary": 2,
|
||||
}
|
||||
# --- Molecule weight configuration ---
|
||||
#
|
||||
# Weights are loaded from ``[tool.devx.molecule.weights]`` in
|
||||
# ``pyproject.toml``. Each project (infra, grm, …) contributes its own
|
||||
# weights calibrated from actual CI execution times.
|
||||
#
|
||||
# Two key formats are supported:
|
||||
# - ``"scenario" = weight`` — applies to any role with that scenario name
|
||||
# - ``"role/scenario" = weight`` — role-specific (takes priority)
|
||||
#
|
||||
# Example pyproject.toml::
|
||||
#
|
||||
# [tool.devx.molecule.weights]
|
||||
# "nextcloud" = 15
|
||||
# "app_container/customer-apps" = 11
|
||||
# "restore/default" = 11
|
||||
# "default" = 3
|
||||
#
|
||||
# If no configuration is found, a generic default weight is used for all
|
||||
# scenarios (producing a round-robin distribution).
|
||||
|
||||
_DEFAULT_SCENARIO_WEIGHT = 3
|
||||
|
||||
|
||||
def _scenario_weight(scenario: str) -> int:
|
||||
"""Estimate a weight for a scenario based on its name."""
|
||||
def _load_molecule_weights(pyproject_path: str = "pyproject.toml") -> tuple[dict[str, int], dict[tuple[str, str], int]]:
|
||||
"""Load molecule weights from ``[tool.devx.molecule.weights]`` in pyproject.toml.
|
||||
|
||||
Returns a tuple of ``(scenario_weights, role_scenario_weights)``:
|
||||
- ``scenario_weights``: maps scenario name → weight (applies to any role)
|
||||
- ``role_scenario_weights``: maps (role, scenario) → weight (role-specific)
|
||||
"""
|
||||
path = Path(pyproject_path)
|
||||
if not path.exists():
|
||||
return {}, {}
|
||||
try:
|
||||
with open(path, "rb") as f: # noqa: PTH123
|
||||
data = tomllib.load(f)
|
||||
except (tomllib.TOMLDecodeError, OSError):
|
||||
return {}, {}
|
||||
|
||||
weights_raw = data.get("tool", {}).get("devx", {}).get("molecule", {}).get("weights", {})
|
||||
if not isinstance(weights_raw, dict):
|
||||
return {}, {}
|
||||
|
||||
scenario_weights: dict[str, int] = {}
|
||||
role_scenario_weights: dict[tuple[str, str], int] = {}
|
||||
|
||||
for key, value in weights_raw.items():
|
||||
if not isinstance(value, int):
|
||||
continue
|
||||
if "/" in key:
|
||||
role, scenario = key.split("/", 1)
|
||||
role_scenario_weights[(role.lower(), scenario.lower())] = value
|
||||
else:
|
||||
scenario_weights[key.lower()] = value
|
||||
|
||||
return scenario_weights, role_scenario_weights
|
||||
|
||||
|
||||
# Load weights once at import time (like devx.config and classify_changes)
|
||||
_SCENARIO_WEIGHTS, _ROLE_SCENARIO_WEIGHTS = _load_molecule_weights()
|
||||
|
||||
|
||||
def _scenario_weight(scenario: str, role: str | None = None) -> int:
|
||||
"""Estimate a weight for a scenario based on its name and optionally its role.
|
||||
|
||||
Role-specific weights (``"role/scenario"``) take priority over
|
||||
scenario-name-only weights (``"scenario"``). Falls back to the
|
||||
default weight if no configuration matches.
|
||||
"""
|
||||
s = scenario.lower()
|
||||
if role is not None:
|
||||
r = role.lower()
|
||||
key = (r, s)
|
||||
if key in _ROLE_SCENARIO_WEIGHTS:
|
||||
return _ROLE_SCENARIO_WEIGHTS[key]
|
||||
for key, weight in _SCENARIO_WEIGHTS.items():
|
||||
if key in s:
|
||||
return weight
|
||||
@@ -181,11 +237,11 @@ def _lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) ->
|
||||
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
|
||||
"""Split *pairs* into *max_runners* balanced groups using LPT scheduling.
|
||||
|
||||
Each pair is weighted by scenario name heuristics (e.g. ``nextcloud`` is
|
||||
heavier than ``binary``). Pairs are sorted by weight descending and
|
||||
Each pair is weighted by role+scenario heuristics (e.g. ``nextcloud`` is
|
||||
heavier than ``simple-app``). Pairs are sorted by weight descending and
|
||||
assigned to the runner with the least total weight.
|
||||
"""
|
||||
weights = [_scenario_weight(p.scenario) for p in pairs]
|
||||
weights = [_scenario_weight(p.scenario, p.role) for p in pairs]
|
||||
return _lpt_distribute(pairs, weights, max_runners)
|
||||
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ def _check_file(
|
||||
legitimate_indicators: list[str],
|
||||
repo_path_prefixes: list[str],
|
||||
min_path_ref_length: int,
|
||||
skip_ref_prefixes: list[str],
|
||||
) -> list[str]:
|
||||
"""Check a single file for stale references."""
|
||||
issues: list[str] = []
|
||||
@@ -147,6 +148,9 @@ def _check_file(
|
||||
# Only check references that look like repo paths
|
||||
if not any(ref.startswith(prefix) for prefix in repo_path_prefixes):
|
||||
continue
|
||||
# Skip references matching configured skip prefixes (e.g. aspirational test files)
|
||||
if any(ref.startswith(prefix) for prefix in skip_ref_prefixes):
|
||||
continue
|
||||
candidate = repo_root / ref
|
||||
if not candidate.exists():
|
||||
issues.append(f"{rel_path}:{lineno}: references non-existent file '{ref}'")
|
||||
@@ -179,6 +183,9 @@ def cli() -> None:
|
||||
min_len_raw = cfg.get("min_path_ref_length")
|
||||
min_path_ref_length: int = int(min_len_raw) if isinstance(min_len_raw, int) else MIN_PATH_REF_LENGTH_DEFAULT
|
||||
|
||||
skip_prefixes_raw = cfg.get("skip_ref_prefixes", [])
|
||||
skip_ref_prefixes: list[str] = [str(d) for d in skip_prefixes_raw] if isinstance(skip_prefixes_raw, list) else []
|
||||
|
||||
deleted_files: set[str] = set()
|
||||
deleted_raw = cfg.get("deleted_files", [])
|
||||
if isinstance(deleted_raw, list):
|
||||
@@ -209,6 +216,7 @@ def cli() -> None:
|
||||
legitimate_indicators,
|
||||
repo_path_prefixes,
|
||||
min_path_ref_length,
|
||||
skip_ref_prefixes,
|
||||
)
|
||||
all_issues.extend(issues)
|
||||
|
||||
|
||||
@@ -1846,5 +1846,13 @@
|
||||
"pl": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"ru": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"zh": "[check-dep-docs] Passed: all dependencies are documented"
|
||||
},
|
||||
"Wrote tag {tag} to GITHUB_OUTPUT.": {
|
||||
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"de": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"en": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"zh": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"pl": "Wrote tag {tag} to GITHUB_OUTPUT."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class TestCheckFile:
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_text("See scripts/old.py for details.\n")
|
||||
issues = _check_file(
|
||||
doc, tmp_path, {"scripts/old.py"}, [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT
|
||||
doc, tmp_path, {"scripts/old.py"}, [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, []
|
||||
)
|
||||
assert any("deleted file" in i for i in issues)
|
||||
|
||||
@@ -97,7 +97,7 @@ class TestCheckFile:
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_text("See scripts/nonexistent.py for details.\n")
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
|
||||
assert any("non-existent file" in i for i in issues)
|
||||
|
||||
def test_does_not_flag_existing_file(self, tmp_path: Path) -> None:
|
||||
@@ -106,7 +106,7 @@ class TestCheckFile:
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_text("See scripts/exists.py for details.\n")
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
|
||||
assert issues == []
|
||||
|
||||
def test_detects_deprecated_pattern(self, tmp_path: Path) -> None:
|
||||
@@ -115,7 +115,7 @@ class TestCheckFile:
|
||||
doc.write_text("Use ansible/envs/prod/secrets.yml for config.\n")
|
||||
patterns = [re.compile(r"ansible/envs/[^/]+/secrets\.yml")]
|
||||
issues = _check_file(
|
||||
doc, tmp_path, set(), patterns, [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT
|
||||
doc, tmp_path, set(), patterns, [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, []
|
||||
)
|
||||
assert any("deprecated pattern" in i for i in issues)
|
||||
|
||||
@@ -129,7 +129,7 @@ class TestCheckFile:
|
||||
doc.write_text("The legacy ansible/envs/prod/secrets.yml is deprecated.\n")
|
||||
patterns = [re.compile(r"ansible/envs/[^/]+/secrets\.yml")]
|
||||
issues = _check_file(
|
||||
doc, tmp_path, set(), patterns, ["deprecated"], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT
|
||||
doc, tmp_path, set(), patterns, ["deprecated"], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, []
|
||||
)
|
||||
assert issues == []
|
||||
|
||||
@@ -137,14 +137,14 @@ class TestCheckFile:
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_bytes(b"\xff\xfe\x00\x00")
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
|
||||
assert issues == []
|
||||
|
||||
def test_skips_short_ref(self, tmp_path: Path) -> None:
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_text("See a.py for details.\n")
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, 5)
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, 5, [])
|
||||
# "a.py" is only 4 chars, below min_path_ref_length
|
||||
assert issues == []
|
||||
|
||||
@@ -152,10 +152,19 @@ class TestCheckFile:
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_text("See vendor/some/long/path.py for details.\n")
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
|
||||
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, [])
|
||||
# "vendor/" is not in repo_path_prefixes
|
||||
assert issues == []
|
||||
|
||||
def test_skip_ref_prefixes_skips_nonexistent(self, tmp_path: Path) -> None:
|
||||
doc = tmp_path / "docs" / "guide.md"
|
||||
doc.parent.mkdir(parents=True)
|
||||
doc.write_text("See scripts/test_foo.py for details.\n")
|
||||
issues = _check_file(
|
||||
doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT, ["scripts/test_"]
|
||||
)
|
||||
assert issues == []
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_passes_when_no_issues(self, tmp_path: Path) -> None:
|
||||
|
||||
@@ -13,6 +13,7 @@ from devx.molecule.distribute_molecule import (
|
||||
PLATFORMS,
|
||||
MultiRoleTestPair,
|
||||
TestPair,
|
||||
_load_molecule_weights,
|
||||
_lpt_distribute,
|
||||
_scenario_weight,
|
||||
build_multi_role_pairs,
|
||||
@@ -482,23 +483,90 @@ class TestCliMultiRole:
|
||||
|
||||
|
||||
class TestScenarioWeight:
|
||||
def test_known_heavy_scenario(self) -> None:
|
||||
assert _scenario_weight("nextcloud") == 10
|
||||
assert _scenario_weight("gitea") == 8
|
||||
|
||||
def test_known_light_scenario(self) -> None:
|
||||
assert _scenario_weight("binary") == 2
|
||||
|
||||
def test_default_weight(self) -> None:
|
||||
def test_default_weight_no_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Without pyproject.toml, all scenarios get the default weight."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
scenario_w, role_w = _load_molecule_weights()
|
||||
assert scenario_w == {}
|
||||
assert role_w == {}
|
||||
assert _scenario_weight("unknown-scenario") == 3
|
||||
|
||||
def test_case_insensitive(self) -> None:
|
||||
assert _scenario_weight("NextCloud") == 10
|
||||
assert _scenario_weight("GITEA") == 8
|
||||
def test_load_weights_from_pyproject(self, tmp_path: Path) -> None:
|
||||
"""Weights are loaded from [tool.devx.molecule.weights] in pyproject.toml."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text(
|
||||
"[tool.devx.molecule.weights]\n"
|
||||
'"nextcloud" = 15\n'
|
||||
'"default" = 3\n'
|
||||
'"binary" = 2\n'
|
||||
'"app_container/customer-apps" = 11\n'
|
||||
'"restore/default" = 11\n'
|
||||
)
|
||||
scenario_w, role_w = _load_molecule_weights(str(pyproject))
|
||||
assert scenario_w == {"nextcloud": 15, "default": 3, "binary": 2}
|
||||
assert role_w == {("app_container", "customer-apps"): 11, ("restore", "default"): 11}
|
||||
|
||||
def test_substring_match(self) -> None:
|
||||
assert _scenario_weight("nextcloud-with-redis") == 10
|
||||
assert _scenario_weight("custom-gitea-setup") == 8
|
||||
def test_role_specific_takes_priority(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Role-specific weights take priority over scenario-name-only weights."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text(
|
||||
'[tool.devx.molecule.weights]\n"default" = 3\n"docker_base/default" = 8\n"restore/default" = 11\n'
|
||||
)
|
||||
scenario_w, role_w = _load_molecule_weights(str(pyproject))
|
||||
monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w)
|
||||
monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w)
|
||||
assert _scenario_weight("default", "docker_base") == 8
|
||||
assert _scenario_weight("default", "restore") == 11
|
||||
assert _scenario_weight("default", "app_container") == 3
|
||||
|
||||
def test_case_insensitive(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Weight keys are matched case-insensitively."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[tool.devx.molecule.weights]\n"nextcloud" = 15\n')
|
||||
scenario_w, role_w = _load_molecule_weights(str(pyproject))
|
||||
monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w)
|
||||
monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w)
|
||||
assert _scenario_weight("NextCloud") == 15
|
||||
assert _scenario_weight("NEXTCLOUD") == 15
|
||||
|
||||
def test_substring_match(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Scenario-name weights use substring matching."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[tool.devx.molecule.weights]\n"nextcloud" = 15\n')
|
||||
scenario_w, role_w = _load_molecule_weights(str(pyproject))
|
||||
monkeypatch.setattr("devx.molecule.distribute_molecule._SCENARIO_WEIGHTS", scenario_w)
|
||||
monkeypatch.setattr("devx.molecule.distribute_molecule._ROLE_SCENARIO_WEIGHTS", role_w)
|
||||
assert _scenario_weight("nextcloud-with-redis") == 15
|
||||
|
||||
def test_no_pyproject_returns_empty(self, tmp_path: Path) -> None:
|
||||
"""Missing pyproject.toml returns empty weight dicts."""
|
||||
scenario_w, role_w = _load_molecule_weights(str(tmp_path / "nonexistent.toml"))
|
||||
assert scenario_w == {}
|
||||
assert role_w == {}
|
||||
|
||||
def test_invalid_weights_ignored(self, tmp_path: Path) -> None:
|
||||
"""Non-integer weight values are silently ignored."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[tool.devx.molecule.weights]\n"good" = 5\n"bad" = "not an int"\n')
|
||||
scenario_w, role_w = _load_molecule_weights(str(pyproject))
|
||||
assert scenario_w == {"good": 5}
|
||||
assert role_w == {}
|
||||
|
||||
def test_malformed_toml_returns_empty(self, tmp_path: Path) -> None:
|
||||
"""Malformed TOML returns empty weight dicts."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text("this is not valid toml = = =")
|
||||
scenario_w, role_w = _load_molecule_weights(str(pyproject))
|
||||
assert scenario_w == {}
|
||||
assert role_w == {}
|
||||
|
||||
def test_non_dict_weights_returns_empty(self, tmp_path: Path) -> None:
|
||||
"""If [tool.devx.molecule.weights] is not a table, returns empty dicts."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text('[tool.devx.molecule]\nweights = "not a table"\n')
|
||||
scenario_w, role_w = _load_molecule_weights(str(pyproject))
|
||||
assert scenario_w == {}
|
||||
assert role_w == {}
|
||||
|
||||
|
||||
class TestLptDistribute:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Unit tests for scripts/ci/release.py."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
@@ -815,11 +817,23 @@ class TestCommitReleaseChanges:
|
||||
class TestCreateAndPushTag:
|
||||
@patch("devx.ci.release.tag_exists", return_value=False)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
create_and_push_tag("0.2.0", "changelog", dry_run=False)
|
||||
def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock, tmp_path: Path) -> None:
|
||||
github_output = tmp_path / "output.txt"
|
||||
with patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output)}):
|
||||
create_and_push_tag("0.2.0", "changelog", dry_run=False)
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
|
||||
assert ["git", "push", "origin", "refs/tags/v0.2.0"] in calls
|
||||
assert github_output.read_text() == "tag=v0.2.0\n"
|
||||
|
||||
@patch("devx.ci.release.tag_exists", return_value=False)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_no_github_output_skips_write(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
create_and_push_tag("0.2.0", "changelog", dry_run=False)
|
||||
# Should still create tag, just not write GITHUB_OUTPUT
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
|
||||
|
||||
@patch("devx.ci.release.tag_exists", return_value=False)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
|
||||
Reference in New Issue
Block a user