DEVX-146: feat: add --include-roles and --exclude-roles to distribute_molecule

Co-authored-by: oblachno Admin <admin@oblachno.oblachno.fyi>
This commit is contained in:
oblachno Admin
2026-08-07 14:20:54 +00:00
committed by emil User
parent 0df79fed53
commit 6601d90bee
21 changed files with 294 additions and 85 deletions
+12
View File
@@ -0,0 +1,12 @@
extends: existence
message: "Don't attribute human qualities to software or hardware ('%s')."
link: https://developers.google.com/style/anthropomorphism
level: suggestion
ignorecase: true
# Limited to the two verbs the guide itself names. Broader lists (wants, knows,
# thinks) can't tell a software subject from a human one: on a 950-file corpus
# they produced 8 false positives ('the customer wants', 'your audience knows')
# for every 2 real ones.
tokens:
- sees
- tells
+7 -2
View File
@@ -1,8 +1,13 @@
extends: existence extends: existence
message: "'%s' should be in lowercase." message: "'%s' should be in lowercase."
link: 'https://developers.google.com/style/colons' link: 'https://developers.google.com/style/colons'
nonword: true
level: warning level: warning
scope: sentence scope: sentence
# The match is the word itself, not ': X', and `nonword` is off. Both are
# required for a project Vocab to work: Vale compares accept.txt entries
# against the matched text, and `nonword: true` opts out of that entirely.
# So a proper noun after a colon can be exempted by adding it to accept.txt.
# The guide's other exemption, notice labels, is handled by the lookbehinds;
# headings are already excluded by `scope: sentence`. See issue #20.
tokens: tokens:
- '(?<!:[^ ]+?):\s[A-Z]' - '(?<!Note: )(?<!Caution: )(?<!Warning: )(?<!Success: )(?<=:\s)[A-Z]\w+'
+1 -1
View File
@@ -6,4 +6,4 @@ level: error
nonword: true nonword: true
tokens: tokens:
- '\d{1,2}(?:\.|/)\d{1,2}(?:\.|/)\d{4}' - '\d{1,2}(?:\.|/)\d{1,2}(?:\.|/)\d{4}'
- '\d{1,2} (?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)|May|Jun(?:e)|Jul(?:y)|Aug(?:ust)|Sep(?:tember)?|Oct(?:ober)|Nov(?:ember)?|Dec(?:ember)?) \d{4}' - '\d{1,2} (?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?) \d{4}'
+14
View File
@@ -0,0 +1,14 @@
extends: existence
message: "Avoid the unverifiable claim '%s'."
link: https://developers.google.com/style/excessive-claims
level: suggestion
ignorecase: true
# The guide also names 'never', 'always', and 'ensure', but in technical writing
# those are usually legitimate instructions ('never commit secrets') rather than
# product claims: they accounted for 125 of 142 hits on a 950-file corpus.
# 'best practices' is a fixed term, not a superlative.
tokens:
- 'best(?! practices?)'
- simplest
- fastest
- guarantees?
+6 -4
View File
@@ -3,11 +3,13 @@ message: "Avoid first-person pronouns such as '%s'."
link: 'https://developers.google.com/style/pronouns#personal-pronouns' link: 'https://developers.google.com/style/pronouns#personal-pronouns'
ignorecase: true ignorecase: true
level: warning level: warning
nonword: true # The 'I' tokens use lookaround rather than consuming the surrounding
# whitespace. Matching ' I ' made the alert span cover both spaces, which shows
# up as a too-wide underline in editors, and read as "such as ' I '". Dropping
# `nonword` also lets a project Vocab apply, which it can't when set. See PR #50.
tokens: tokens:
- (?:^|\s)I\s - '(?<=^|\s)I(?=[\s,])'
- (?:^|\s)I,\s - "\\bI'm\\b"
- \bI'm\b
- \bme\b - \bme\b
- \bmy\b - \bmy\b
- \bmine\b - \bmine\b
+5 -2
View File
@@ -4,8 +4,11 @@ link: "https://developers.google.com/style/capitalization#capitalization-in-titl
level: warning level: warning
scope: heading scope: heading
match: $sentence match: $sentence
indicators: # No `indicators: [":"]` here. That makes Vale require a capital after a colon,
- ":" # which is the Microsoft convention this rule was originally copied from. This
# guide says the opposite: "the first word after a colon is generally
# lowercase" (developers.google.com/style/colons), and Colons.yml enforces
# exactly that. See issue #58.
exceptions: exceptions:
- Azure - Azure
- CLI - CLI
+13
View File
@@ -0,0 +1,13 @@
extends: existence
message: "Avoid the jargon '%s'."
link: https://developers.google.com/style/jargon
level: suggestion
ignorecase: true
# The guide also cites 'solution', 'support', and 'workload' as overloaded
# terms, but those have ordinary technical meanings and accounted for every hit
# on a 950-file corpus, so only the unambiguous figurative terms are listed.
tokens:
- break-glass
- camel ?case
- out-of-the-box
- swim ?lane
+6 -2
View File
@@ -6,6 +6,10 @@ level: error
nonword: true nonword: true
action: action:
name: replace name: replace
# The delimiter is a lookahead so the replacement doesn't swallow the comma or
# space that follows (issue #18). `$` is included so the abbreviation is still
# caught at the end of a heading, table cell, or block, which accounted for 8
# of 10 occurrences on a 950-file corpus.
swap: swap:
'\b(?:eg|e\.g\.)(?=[\s,;])': for example '\b(?:eg|e\.g\.)(?=[\s,;]|$)': for example
'\b(?:ie|i\.e\.)(?=[\s,;])': that is '\b(?:ie|i\.e\.)(?=[\s,;]|$)': that is
+22 -1
View File
@@ -3,5 +3,26 @@ message: "Use the Oxford comma in '%s'."
link: 'https://developers.google.com/style/commas' link: 'https://developers.google.com/style/commas'
scope: sentence scope: sentence
level: warning level: warning
nonword: true
# List items may be several words long, not just one. Four guards keep the
# false-positive rate down:
#
# 1. The comma can't be the one closing a fronted subordinate clause
# ('When your alarm rings, you turn it off and tumble out of bed.') --
# that comma separates clauses, not list items. Only the first comma of
# such a sentence is exempt, so 'When it rains, apples, pears or bananas
# get wet.' is still caught.
# 2. The item can't open with a clause-introducer (', which ...',
# ', specifically ...').
# 3. The item can't open with a subject pronoun followed by a verb, which
# marks a compound predicate rather than a list ('..., you walk to the
# fridge and get a snack.'). A pronoun directly followed by 'and'/'or'
# is a real list item, so ', you and me.' still matches.
# 4. Neither item may contain an auxiliary verb, which is another compound
# predicate signal (', it has some downsides and is officially
# discouraged.').
#
# The trailing anchor allows end-of-scope so list fragments ('Apples, pears
# or bananas') are still caught.
tokens: tokens:
- '(?:[^,]+,){1,}\s\w+\s(?:and|or)' - '(?<!^(?i:when|whenever|while|if|unless|until|although|though|because|since|after|before|once|whereas|whether|as)\b[^,]{0,80}),\s(?!(?:which|who|whom|whose|that|where|when|while|because|since|although|though|if|unless|so|but|and|or|however|therefore|thus|specifically|especially|namely|then|take|see|note|consider|make|use|either|neither)\b)(?!(?i:i|you|we|they|he|she|it)\s+(?!(?:and|or)\b))(?:(?!\b(?:is|are|was|were|has|have|had|be|been|being|will|would|can|could|should|may|might|must|do|does|did)\b)\w+ ){0,4}\w+ (?:and|or) (?:(?!\b(?:is|are|was|were|has|have|had|be|been|being|will|would|can|could|should|may|might|must|do|does|did)\b)\w+ ){0,4}\w+(?:[.?!]|$)'
+9 -1
View File
@@ -3,5 +3,13 @@ message: "Use parentheses judiciously."
link: 'https://developers.google.com/style/parentheses' link: 'https://developers.google.com/style/parentheses'
nonword: true nonword: true
level: suggestion level: suggestion
# `[^)]` rather than `.+`: a greedy match ran from the first '(' on a line to
# the last ')', so 'Text (one) and more (two).' produced a single alert
# covering everything between them. See issue #30.
# A bare 3-5 letter acronym is skipped: Acronyms.yml requires acronyms to be
# defined as 'Spelled Out Term (ACRONYM)', so flagging those parentheses would
# put the two rules in direct conflict. The acronym has to be the whole
# parenthetical — '(NASA rocket program)' is an ordinary aside and still
# flags. Length matches the {3,5} in Acronyms.yml. See PR #59.
tokens: tokens:
- '\(.+\)' - '\((?![A-Z]{3,5}\))[^)]+\)'
+13
View File
@@ -0,0 +1,13 @@
extends: existence
message: "Avoid time-based words like '%s' in product documentation."
link: https://developers.google.com/style/timeless-documentation
level: suggestion
ignorecase: true
# The guide also names 'now' and 'new', but both have common senses that aren't
# time-anchored ('create a new project'): adding them took a 950-file corpus of
# technical documentation from 14 hits to 117. 'recently' is left out too — every
# hit in that corpus was the UI idiom 'recently used'.
tokens:
- currently
- latest
- soon
+4 -2
View File
@@ -4,5 +4,7 @@ link: "https://developers.google.com/style/units-of-measure"
nonword: true nonword: true
level: error level: error
tokens: tokens:
- \b\d+(?:B|kB|MB|GB|TB) - '\b\d+(?:B|kB|MB|GB|TB)\b'
- \b\d+(?:ns|ms|s|min|h|d) - '\b\d+(?:ns|ms|min|h|d)\b'
# Seconds are split out so a decade ('1990s') isn't read as a unit.
- '\b\d+s\b(?<!\b(?:19|20)\d\ds\b)'
+3 -54
View File
@@ -2,79 +2,28 @@ extends: substitution
message: "Use '%s' instead of '%s'." message: "Use '%s' instead of '%s'."
link: "https://developers.google.com/style/word-list" link: "https://developers.google.com/style/word-list"
level: warning level: warning
# Case matters here: each key's own capitalization is what's being corrected,
# so ignorecase would make these match their own replacements. The rest of the
# word list lives in WordListCase.yml.
ignorecase: false ignorecase: false
action: action:
name: replace name: replace
swap: swap:
"(?:API Console|dev|developer) key": API key
"(?:cell ?phone|smart ?phone)": phone|mobile phone
"(?:dev|developer|APIs) console": API console
"(?:e-mail|Email|E-mail)": email
"(?:file ?path|path ?name)": path
"(?:kill|terminate|abort)": stop|exit|cancel|end
"(?:OAuth ?2|Oauth)": OAuth 2.0
"(?:ok|Okay)": OK|okay
"(?:WiFi|wifi)": Wi-Fi
'[\.]+apk': APK
'3\-D': 3D
'Google (?:I\-O|IO)': Google I/O
"tap (?:&|and) hold": touch & hold
"un(?:check|select)": clear
above: preceding
account name: username
action bar: app bar
admin: administrator
Ajax: AJAX Ajax: AJAX
a\.k\.a|aka: or|also known as
Android device: Android-powered device Android device: Android-powered device
android: Android android: Android
API explorer: APIs Explorer API explorer: APIs Explorer
application: app
approx\.: approximately
authN: authentication authN: authentication
authZ: authorization authZ: authorization
autoupdate: automatically update
cellular data: mobile data
cellular network: mobile network
chapter: documents|pages|sections
check box: checkbox
CLI: command-line tool CLI: command-line tool
click on: click|click in
Cloud: Google Cloud Platform|GCP Cloud: Google Cloud Platform|GCP
Container Engine: Kubernetes Engine Container Engine: Kubernetes Engine
content type: media type
curated roles: predefined roles
data are: data is
Developers Console: Google API Console|API Console Developers Console: Google API Console|API Console
disabled?: turn off|off
ephemeral IP address: ephemeral external IP address
fewer data: less data
file name: filename
firewalls: firewall rules
functionality: capability|feature
Google account: Google Account Google account: Google Account
Google accounts: Google Accounts Google accounts: Google Accounts
Googling: search with Google Googling: search with Google
grayed-out: unavailable
HTTPs: HTTPS HTTPs: HTTPS
in order to: to
ingest: import|load
k8s: Kubernetes k8s: Kubernetes
long press: touch & hold
network IP address: internal IP address
omnibox: address bar
open-source: open source
overview screen: recents screen
regex: regular expression
SHA1: SHA-1|HAS-SHA1 SHA1: SHA-1|HAS-SHA1
sign into: sign in to
sign-?on: single sign-on
static IP address: static external IP address
stylesheet: style sheet
synch: sync
tablename: table name
tablet: device
touch: tap
url: URL url: URL
vs\.: versus
World Wide Web: web World Wide Web: web
+68
View File
@@ -0,0 +1,68 @@
extends: substitution
message: "Use '%s' instead of '%s'."
link: "https://developers.google.com/style/word-list"
level: warning
# The case-insensitive half of the word list, so sentence-initial use is caught
# ('Touch the screen', not only 'touch the screen'). Entries that must stay
# case-sensitive are in WordList.yml.
ignorecase: true
action:
name: replace
swap:
"(?:API Console|dev|developer) key": API key
"(?:cell ?phone|smart ?phone)": phone|mobile phone
"(?:dev|developer|APIs) console": API console
"(?:e-mail|Email|E-mail)": email
"(?:file ?path|path ?name)": path
"(?:kill|terminate|abort)": stop|exit|cancel|end
# Longest form first: with the shortest alternative leading, 'OAuth 2' matched
# only 'OAuth', so applying the suggestion produced 'OAuth 2.0 2'. The rule is
# already case-insensitive, so the inline (?i) is redundant. See issue #41.
'\bOauth2\.0\b|\bOAuth ?2\b(?!\.0)|\bOauth\b(?! ?2)': OAuth 2.0
"(?:ok|Okay)": OK|okay
"(?:WiFi|wifi)": Wi-Fi
'[\.]+apk': APK
'3\-D': 3D
'Google (?:I\-O|IO)': Google I/O
"tap (?:&|and) hold": touch & hold
"un(?:check|select)": clear
above: preceding
account name: username
action bar: app bar
admin: administrator
a\.k\.a|aka: or|also known as
application: app
approx\.: approximately
autoupdate: automatically update
cellular data: mobile data
cellular network: mobile network
chapter: documents|pages|sections
check box: checkbox
click on: click|click in
content type: media type
curated roles: predefined roles
data are: data is
disabled?: turn off|off
ephemeral IP address: ephemeral external IP address
fewer data: less data
file name: filename
firewalls: firewall rules
functionality: capability|feature
grayed-out: unavailable
in order to: to
ingest: import|load
long press: touch & hold
network IP address: internal IP address
omnibox: address bar
open-source: open source
overview screen: recents screen
regex: regular expression
sign into: sign in to
'(?<!single )sign-?on': single sign-on
static IP address: static external IP address
stylesheet: style sheet
synch: sync
tablename: table name
tablet: device
'touch(?! ?(?:&|and) hold)': tap
vs\.: versus
+1 -1
View File
@@ -12,7 +12,7 @@ opinionated CI/CD pipeline: conventional commits, automated versioning via
git-cliff, squash-merge automation, Vikunja task tracking, wiki sync, and git-cliff, squash-merge automation, Vikunja task tracking, wiki sync, and
quality badges. quality badges.
> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian). > An open source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
+1 -1
View File
@@ -8,7 +8,7 @@ parallel test distribution, and more into a single installable package.
It was extracted from the [GRM](https://git.oblachno.oblachno.fyi/oblachno-oss/grm) It was extracted from the [GRM](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
project to be reusable across all oblachno-oss repositories. project to be reusable across all oblachno-oss repositories.
> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian). > An open source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
@@ -132,7 +132,7 @@ unblocked auto-merge across all three repos.
### 2. Double-Prefix Detection (MEDIUM impact) ### 2. Double-Prefix Detection (MEDIUM impact)
`check_auto_merge_ready.py` now detects and rejects Vikunja task titles `check_auto_merge_ready.py` now detects and rejects Vikunja task titles
that include the identifier prefix (for example, "DEVX-127: Fix..."). that include the identifier prefix (for example, "DEVX-127: Fix").
The validator adds the prefix automatically, so a double prefix would The validator adds the prefix automatically, so a double prefix would
fail validation. fail validation.
+6 -6
View File
@@ -87,11 +87,11 @@ overridden via environment variables with the `DEVX_` prefix. Provides:
- `GITEA_API_URL` / `VIKUNJA_API_URL` — API endpoints - `GITEA_API_URL` / `VIKUNJA_API_URL` — API endpoints
- `REPO_OWNER` — repository owner (must be set per-project) - `REPO_OWNER` — repository owner (must be set per-project)
- `TASK_PREFIX` / `TASK_ID_RE` — task ID prefix and regex (for example, `DEVX-N`) - `TASK_PREFIX` / `TASK_ID_RE` — task ID prefix and regular expression (for example, `DEVX-N`)
- `VIKUNJA_PROJECT_ID` — Vikunja project for task tracking - `VIKUNJA_PROJECT_ID` — Vikunja project for task tracking
- `DEFAULT_TIMEOUT`, `DEFAULT_PER_PAGE` — HTTP client defaults - `DEFAULT_TIMEOUT`, `DEFAULT_PER_PAGE` — HTTP client defaults
- `MAX_RETRIES`, `RETRY_BACKOFF_BASE`, `RETRY_STATUS_CODES` — retry config - `MAX_RETRIES`, `RETRY_BACKOFF_BASE`, `RETRY_STATUS_CODES` — retry config
- `CONVENTIONAL_RE` — conventional commit format regex - `CONVENTIONAL_RE` — conventional commit format regular expression
### `exceptions.py` ### `exceptions.py`
@@ -109,7 +109,7 @@ wraps user-facing strings for translation.
Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a
custom JSON file. Keys from the project's file are merged on top of devx's custom JSON file. Keys from the project's file are merged on top of devx's
built-in translations, allowing projects to override or add keys without built-in translations, allowing projects to override, or add keys without
modifying the package. modifying the package.
### `api_clients.py` ### `api_clients.py`
@@ -171,7 +171,7 @@ from `devx.api_clients`, `devx.config`, `devx.gitea_cli`, and `devx.i18n`.
Automated release using git-cliff. Calculates the next semver version from Automated release using git-cliff. Calculates the next semver version from
conventional commits since the last tag, updates `__version__` in conventional commits since the last tag, updates `__version__` in
`__init__.py` and `CHANGELOG.md`, runs lint and tests to verify the release `__init__.py` and `CHANGELOG.md`, runs lint, and tests to verify the release
is healthy, commits with `release: vX.Y.Z [skip ci]`, creates an annotated is healthy, commits with `release: vX.Y.Z [skip ci]`, creates an annotated
tag, and pushes both to master. tag, and pushes both to master.
@@ -288,7 +288,7 @@ Click commands from `cli.py` and verifies each has documentation in
### `discover_runners.py` ### `discover_runners.py`
Discovers available Gitea Actions runners at three levels: repository, Discovers available Gitea Actions runners at three levels: repository,
organization, and instance (admin). Falls back to the `MOLECULE_RUNNERS` repo organization, and instance (administrator). Falls back to the `MOLECULE_RUNNERS` repo
variable or `DEFAULT_MAX_RUNNERS` (3). Outputs runner count or a JSON index variable or `DEFAULT_MAX_RUNNERS` (3). Outputs runner count or a JSON index
array for use as a dynamic matrix in Gitea Actions. array for use as a dynamic matrix in Gitea Actions.
@@ -330,7 +330,7 @@ Supports `--tool` to install specific tools and `--list` to show status.
Runs unit tests and enforces execution-time budgets. Two quality gates: Runs unit tests and enforces execution-time budgets. Two quality gates:
total suite time must not exceed `--max-seconds` (default: 10s), and no total suite time must not exceed `--max-seconds` (default: 10s), and no
individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to
disable). Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0`. off). Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0`.
### `check_test_isolation.py` ### `check_test_isolation.py`
+4 -4
View File
@@ -85,7 +85,7 @@ devx ci detect-release-commit
Discover available Gitea Actions runners for dynamic job distribution. Discover available Gitea Actions runners for dynamic job distribution.
Queries the Gitea API for registered runners at repository, organization, and Queries the Gitea API for registered runners at repository, organization, and
instance (admin) levels. Falls back to `MOLECULE_RUNNERS` repo variable or instance (administrator) levels. Falls back to `MOLECULE_RUNNERS` repo variable or
`DEFAULT_MAX_RUNNERS` (3). `DEFAULT_MAX_RUNNERS` (3).
```bash ```bash
@@ -373,7 +373,7 @@ Run unit tests and enforce execution-time budgets. Two quality gates:
- **Total suite time** must not exceed `--max-seconds` (default: 10s) - **Total suite time** must not exceed `--max-seconds` (default: 10s)
- **Per-test time** — no individual test may exceed `--max-single-seconds` - **Per-test time** — no individual test may exceed `--max-single-seconds`
(default: 0.5s, 0 to disable) (default: 0.5s, 0 to turn off)
Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0` so pytest emits Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0` so pytest emits
per-test timing lines. per-test timing lines.
@@ -419,7 +419,7 @@ devx tools check-test-isolation --src-dir src/
Pytest plugin options (automatic when devx is installed): Pytest plugin options (automatic when devx is installed):
- `--no-test-isolation` — disable static analysis and runtime subprocess audit - `--no-test-isolation` — turn off static analysis and runtime subprocess audit
- `--test-isolation-max-loop N` — max iterations per loop (default: 100) - `--test-isolation-max-loop N` — max iterations per loop (default: 100)
### `devx tools configure-repo` ### `devx tools configure-repo`
@@ -464,7 +464,7 @@ devx tools generate-cliff-config --prefix GRM --force # overwrite existing
Options: Options:
- `--prefix <prefix>` — task ID prefix (default: `DEVX_TASK_PREFIX` env var - `--prefix <prefix>` — task ID prefix (default: `DEVX_TASK_PREFIX` env var
or `DEVX`) or `DEVX`)
- `--output <file>` — output file path (default: `cliff.toml`) - `--output <file>` — output path (default: `cliff.toml`)
- `--force` — overwrite existing file - `--force` — overwrite existing file
### `devx tools install-checkmake` ### `devx tools install-checkmake`
+42 -2
View File
@@ -104,21 +104,36 @@ def discover_scenarios(root: Path | None = None) -> list[str]:
return sorted(scenarios) return sorted(scenarios)
def discover_multi_role_scenarios(roles_root: Path | None = None) -> list[tuple[str, str]]: def discover_multi_role_scenarios(
roles_root: Path | None = None,
include_roles: list[str] | None = None,
exclude_roles: list[str] | None = None,
) -> list[tuple[str, str]]:
"""Discover (role, scenario) pairs across all roles under *roles_root*. """Discover (role, scenario) pairs across all roles under *roles_root*.
Scans ``roles_root/*/molecule/*/`` for scenario directories, skipping Scans ``roles_root/*/molecule/*/`` for scenario directories, skipping
``common`` and directories starting with ``_``. Returns a sorted list of ``common`` and directories starting with ``_``. Returns a sorted list of
``(role_name, scenario_name)`` tuples. ``(role_name, scenario_name)`` tuples.
If *include_roles* is given, only roles whose name is in the list are
returned. If *exclude_roles* is given, roles whose name is in the list
are skipped. Both filters are case-insensitive.
""" """
if roles_root is None: if roles_root is None:
roles_root = DEFAULT_ROLES_ROOT roles_root = DEFAULT_ROLES_ROOT
if not roles_root.is_dir(): if not roles_root.is_dir():
raise click.ClickException(_("Roles directory not found: {path}", path=str(roles_root))) raise click.ClickException(_("Roles directory not found: {path}", path=str(roles_root)))
include_set = {r.lower() for r in include_roles} if include_roles else None
exclude_set = {r.lower() for r in exclude_roles} if exclude_roles else None
pairs: list[tuple[str, str]] = [] pairs: list[tuple[str, str]] = []
for role_dir in sorted(roles_root.iterdir()): for role_dir in sorted(roles_root.iterdir()):
if not role_dir.is_dir(): if not role_dir.is_dir():
continue continue
role_name = role_dir.name
if include_set is not None and role_name.lower() not in include_set:
continue
if exclude_set is not None and role_name.lower() in exclude_set:
continue
mol_dir = role_dir / "molecule" mol_dir = role_dir / "molecule"
if not mol_dir.is_dir(): if not mol_dir.is_dir():
continue continue
@@ -348,6 +363,24 @@ def _write_github_env(key: str, value: str) -> None:
help="JSON file with custom platform list (each entry: name, image, command). " help="JSON file with custom platform list (each entry: name, image, command). "
"Overrides the default platform matrix. Useful for projects with custom test images.", "Overrides the default platform matrix. Useful for projects with custom test images.",
) )
@click.option(
"--include-roles",
"include_roles",
type=str,
default=None,
help="Comma-separated list of role names to include (multi-role mode only). "
"Only scenarios from these roles are distributed. Case-insensitive. "
"Example: --include-roles docker_base,crowdsec,disk_cleanup,app_hardening",
)
@click.option(
"--exclude-roles",
"exclude_roles",
type=str,
default=None,
help="Comma-separated list of role names to exclude (multi-role mode only). "
"Scenarios from these roles are skipped. Case-insensitive. "
"Example: --exclude-roles docker_base,crowdsec,disk_cleanup,app_hardening",
)
def cli( def cli(
runner_index: int | None, runner_index: int | None,
max_runners: int, max_runners: int,
@@ -358,11 +391,18 @@ def cli(
molecule_root: Path | None, molecule_root: Path | None,
roles_root: Path | None, roles_root: Path | None,
platforms_file: Path | None, platforms_file: Path | None,
include_roles: str | None,
exclude_roles: str | None,
) -> None: ) -> None:
platforms = load_platforms(platforms_file) platforms = load_platforms(platforms_file)
# Parse role filters
include_list = [r.strip() for r in include_roles.split(",")] if include_roles else None
exclude_list = [r.strip() for r in exclude_roles.split(",")] if exclude_roles else None
# Multi-role mode: discover (role, scenario) pairs across all roles # Multi-role mode: discover (role, scenario) pairs across all roles
if roles_root is not None: if roles_root is not None:
role_scenarios = discover_multi_role_scenarios(roles_root) role_scenarios = discover_multi_role_scenarios(
roles_root, include_roles=include_list, exclude_roles=exclude_list
)
if list_all: if list_all:
for role, scenario in role_scenarios: for role, scenario in role_scenarios:
click.echo(f"{role}|{scenario}") click.echo(f"{role}|{scenario}")
+55
View File
@@ -311,6 +311,61 @@ class TestDiscoverMultiRole:
with pytest.raises(click.ClickException): with pytest.raises(click.ClickException):
discover_multi_role_scenarios() discover_multi_role_scenarios()
def test_include_roles_filters_to_subset(self, tmp_path: Path) -> None:
roles = tmp_path / "roles"
for scenario in ["default"]:
(roles / "docker_base" / "molecule" / scenario).mkdir(parents=True)
(roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True)
(roles / "app_container" / "molecule" / scenario).mkdir(parents=True)
result = discover_multi_role_scenarios(roles, include_roles=["docker_base", "crowdsec"])
assert ("docker_base", "default") in result
assert ("crowdsec", "default") in result
assert ("app_container", "default") not in result
assert len(result) == 2
def test_include_roles_case_insensitive(self, tmp_path: Path) -> None:
roles = tmp_path / "roles"
(roles / "Docker_Base" / "molecule" / "default").mkdir(parents=True)
(roles / "other" / "molecule" / "default").mkdir(parents=True)
result = discover_multi_role_scenarios(roles, include_roles=["docker_base"])
assert ("Docker_Base", "default") in result
assert len(result) == 1
def test_exclude_roles_skips_subset(self, tmp_path: Path) -> None:
roles = tmp_path / "roles"
for scenario in ["default"]:
(roles / "docker_base" / "molecule" / scenario).mkdir(parents=True)
(roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True)
(roles / "app_container" / "molecule" / scenario).mkdir(parents=True)
result = discover_multi_role_scenarios(roles, exclude_roles=["docker_base", "crowdsec"])
assert ("docker_base", "default") not in result
assert ("crowdsec", "default") not in result
assert ("app_container", "default") in result
assert len(result) == 1
def test_exclude_roles_case_insensitive(self, tmp_path: Path) -> None:
roles = tmp_path / "roles"
(roles / "Docker_Base" / "molecule" / "default").mkdir(parents=True)
(roles / "other" / "molecule" / "default").mkdir(parents=True)
result = discover_multi_role_scenarios(roles, exclude_roles=["docker_base"])
assert ("Docker_Base", "default") not in result
assert ("other", "default") in result
assert len(result) == 1
def test_include_and_exclude_combined(self, tmp_path: Path) -> None:
roles = tmp_path / "roles"
for scenario in ["default"]:
(roles / "docker_base" / "molecule" / scenario).mkdir(parents=True)
(roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True)
(roles / "app_container" / "molecule" / scenario).mkdir(parents=True)
result = discover_multi_role_scenarios(
roles, include_roles=["docker_base", "crowdsec", "app_container"], exclude_roles=["crowdsec"]
)
assert ("docker_base", "default") in result
assert ("crowdsec", "default") not in result
assert ("app_container", "default") in result
assert len(result) == 2
def test_default_roles_root_constant(self) -> None: def test_default_roles_root_constant(self) -> None:
assert Path("ansible/roles") == DEFAULT_ROLES_ROOT assert Path("ansible/roles") == DEFAULT_ROLES_ROOT