From 00a2a10a998452dea71671048ee4f4b3c9249f66 Mon Sep 17 00:00:00 2001 From: Ranabir Chakraborty Date: Fri, 10 Jul 2026 13:26:35 +0530 Subject: [PATCH] AMW-590 Collection spelling and grammar checking for PRs --- .github/vale-lint/action.yml | 47 ++++ .../scripts/check_grammar_spelling.py | 204 ++++++++++++++++++ .github/vale-lint/vale-tasks.ini | 8 + .github/vale-lint/vocabularies/accept.txt | 106 +++++++++ README.md | 117 +++++++++- 5 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 .github/vale-lint/action.yml create mode 100644 .github/vale-lint/scripts/check_grammar_spelling.py create mode 100644 .github/vale-lint/vale-tasks.ini create mode 100644 .github/vale-lint/vocabularies/accept.txt diff --git a/.github/vale-lint/action.yml b/.github/vale-lint/action.yml new file mode 100644 index 0000000..7065473 --- /dev/null +++ b/.github/vale-lint/action.yml @@ -0,0 +1,47 @@ +--- +name: Vale Prose Lint +description: Spell and grammar checks for ansible-middleware collections using Vale + +inputs: + vale-version: + description: Vale version to install + required: false + default: '3.15.1' + min-alert-level: + description: Minimum alert level to report (suggestion, warning, error) + required: false + default: warning + +runs: + using: composite + steps: + - name: Install Vale + shell: bash + run: | + wget -qO- "https://github.com/errata-ai/vale/releases/download/v${{ inputs.vale-version }}/vale_${{ inputs.vale-version }}_Linux_64-bit.tar.gz" \ + | tar xz -C /usr/local/bin vale + + - name: Merge shared vocabulary + shell: bash + run: | + mkdir -p .github/styles/config/vocabularies/Base + cat "${{ github.action_path }}/vocabularies/accept.txt" \ + >> .github/styles/config/vocabularies/Base/accept.txt + + - name: Download Vale styles + shell: bash + run: vale sync + + - name: Lint prose files + shell: bash + run: vale --minAlertLevel "${{ inputs.min-alert-level }}" . + + - name: Install Python dependencies + shell: bash + run: | + sudo apt-get install -y python3-docutils hunspell hunspell-en-us + pip install pyyaml + + - name: Lint Ansible YAML name fields + shell: bash + run: python3 "${{ github.action_path }}/scripts/check_grammar_spelling.py" diff --git a/.github/vale-lint/scripts/check_grammar_spelling.py b/.github/vale-lint/scripts/check_grammar_spelling.py new file mode 100644 index 0000000..7c5e930 --- /dev/null +++ b/.github/vale-lint/scripts/check_grammar_spelling.py @@ -0,0 +1,204 @@ +"""Check grammar and spelling in Ansible YAML prose fields using Vale.""" +import glob +import os +import re +import subprocess +import sys +import tempfile + +try: + import yaml +except ImportError: + print("PyYAML not available, skipping YAML prose check.", file=sys.stderr) + sys.exit(0) + +PROJECT_ROOT = os.getcwd() +ACCEPT_TXT = os.path.join( + PROJECT_ROOT, ".github", "styles", "config", "vocabularies", "Base", "accept.txt" +) + + +def _find_vale_config(): + """Walk up from the working directory to find .vale.ini, mirroring Vale's own discovery.""" + current = PROJECT_ROOT + while True: + candidate = os.path.join(current, '.vale.ini') + if os.path.exists(candidate): + return candidate + parent = os.path.dirname(current) + if parent == current: + return os.path.join(PROJECT_ROOT, '.vale.ini') + current = parent + + +VALE_CONFIG = _find_vale_config() + +JINJA2_RE = re.compile(r'\{\{[^}]+\}\}') + +# Prose-bearing keys extracted from task/handler/playbook/molecule YAML files. +TASK_KEYS = frozenset({'name', 'msg', 'fail_msg', 'success_msg'}) + +# Prose-bearing keys extracted from galaxy.yml and role meta/argument_specs files. +META_KEYS = frozenset({'description', 'short_description'}) + +# Task-specific Vale config: project override takes precedence over action default. +_ACTION_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_PROJECT_TASKS_INI = os.path.join(PROJECT_ROOT, '.github', 'vale-tasks.ini') +_ACTION_TASKS_INI = os.path.join(_ACTION_DIR, 'vale-tasks.ini') +TASKS_INI = _PROJECT_TASKS_INI if os.path.exists(_PROJECT_TASKS_INI) else _ACTION_TASKS_INI + + +def _load_accepted_terms(): + """Load accepted spelling terms from the merged Vale vocabulary file.""" + try: + with open(ACCEPT_TXT) as f: + return {line.strip() for line in f if line.strip()} + except FileNotFoundError: + return set() + + +ACCEPTED_TERMS = _load_accepted_terms() + + +def _build_tasks_config(): + """Return path to a temp Vale config: project base merged with task suppressions.""" + try: + with open(VALE_CONFIG) as f: + base = f.read() + except FileNotFoundError: + base = '' + extra = '' + if os.path.exists(TASKS_INI): + with open(TASKS_INI) as f: + extra = f.read() + config_dir = os.path.dirname(os.path.abspath(VALE_CONFIG)) + with tempfile.NamedTemporaryFile( + mode='w', suffix='.ini', dir=config_dir, delete=False + ) as f: + f.write(base) + if extra: + f.write('\n' + extra) + return f.name + + +def get_prose_fields(filepath, keys): + results = [] + try: + with open(filepath) as f: + raw = f.read() + data = yaml.safe_load(raw) + lines = raw.splitlines() + + def _collect(obj): + if isinstance(obj, dict): + for k, v in obj.items(): + if k in keys: + texts = [v] if isinstance(v, str) else (v if isinstance(v, list) else []) + for text in texts: + if not isinstance(text, str) or not text.strip(): + continue + text = text.strip().strip('"\'') + needle = text.split('{{', maxsplit=1)[0].strip()[:30] + lineno = next( + (i + 1 for i, l in enumerate(lines) + if f'{k}:' in l and needle and needle in l), + 0, + ) + results.append((lineno, text)) + else: + _collect(v) + elif isinstance(obj, list): + for item in obj: + _collect(item) + + _collect(data) + except Exception: + pass + return results + + +def is_accepted(message, rule): + """Return True if the finding should be suppressed.""" + if rule == "Vale.Spelling": + # Extract the flagged word from "Did you really mean 'X'?" + m = re.search(r"'([^']+)'", message) + if m and m.group(1) in ACCEPTED_TERMS: + return True + return False + + +def vale_check(text, config): + """Run Vale on a single string using the given config, returning filtered findings.""" + clean = JINJA2_RE.sub('VALUE', text) + # Skip tokens that look like Ansible variable names (underscore-separated) + clean = re.sub(r'\b(?:[a-z][a-z\d]*_)+[a-z\d_]+\b', 'VARNAME', clean) + + with tempfile.NamedTemporaryFile( + mode='w', suffix='.md', dir=PROJECT_ROOT, delete=False + ) as tmp: + tmp.write(clean + '\n') + tmp_path = tmp.name + + findings = [] + try: + result = subprocess.run( + ['vale', '--config', config, '--output=line', + '--minAlertLevel=warning', tmp_path], + capture_output=True, text=True, check=False, + ) + for line in result.stdout.strip().splitlines(): + # Format: "path:line:col:rule:message" + parts = line.split(':') + if len(parts) < 5: + continue + rule = parts[3].strip() + message = ':'.join(parts[4:]).strip() + if not is_accepted(message, rule): + findings.append((rule, message)) + finally: + os.unlink(tmp_path) + + return findings + + +def _check_files(file_list, keys, config): + found_issues = False + for filepath in file_list: + for lineno, text in get_prose_fields(filepath, keys): + for rule, message in vale_check(text, config): + found_issues = True + print(f"{filepath}:{lineno}: {message} [{text!r}]") + return found_issues + + +def main(): + task_files = sorted(set( + glob.glob('roles/**/tasks/**/*.yml', recursive=True) + + glob.glob('roles/**/tasks/*.yml', recursive=True) + + glob.glob('roles/**/handlers/**/*.yml', recursive=True) + + glob.glob('roles/**/handlers/*.yml', recursive=True) + + glob.glob('playbooks/**/*.yml', recursive=True) + + glob.glob('playbooks/*.yml') + + glob.glob('molecule/**/*.yml', recursive=True) + + glob.glob('molecule/*.yml') + )) + + meta_files = sorted(set( + glob.glob('galaxy.yml') + + glob.glob('roles/*/meta/main.yml') + + glob.glob('roles/*/meta/argument_specs.yml') + )) + + tasks_config = _build_tasks_config() + try: + found_issues = _check_files(task_files, TASK_KEYS, tasks_config) + found_issues = _check_files(meta_files, META_KEYS, tasks_config) or found_issues + finally: + os.unlink(tasks_config) + + if found_issues: + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/.github/vale-lint/vale-tasks.ini b/.github/vale-lint/vale-tasks.ini new file mode 100644 index 0000000..79daf75 --- /dev/null +++ b/.github/vale-lint/vale-tasks.ini @@ -0,0 +1,8 @@ +# Rule suppressions applied only when Vale checks Ansible YAML prose fields +# (task names, handler names, descriptions, etc.). +# These rules generate too many false positives for short imperative strings. +# +# Projects can override this file by creating .github/vale-tasks.ini in their +# repository. The project file takes precedence over this shared default. +write-good.TooWordy = NO +write-good.Weasel = NO diff --git a/.github/vale-lint/vocabularies/accept.txt b/.github/vale-lint/vocabularies/accept.txt new file mode 100644 index 0000000..51d6065 --- /dev/null +++ b/.github/vale-lint/vocabularies/accept.txt @@ -0,0 +1,106 @@ +Ranabir +Chakraborty +Harsha +Cherukuri +Ingo +Weiss +Martin +Cihlar +Cihlář +Romain +Pelisse +Guido +Grazioli +WildFly +wildfly +JBoss +jboss +EAP +eap +Keycloak +keycloak +Prospero +prospero +Temurin +OpenJDK +openjdk +Ansible +ansible +GitHub +github +RHEL +rhel +DNF +RPM +systemd +firewalld +POSIX +JVM +JDK +JDBC +JEE +JGroups +jgroup +YAML +yaml +JSON +json +XML +xml +SSL +API +CLI +cli +WAR +RHN +rhn +FQCN +Jinja +jinja +playbook +playbooks +middleware +prereqs +prereq +argspec +Argspec +uninstall +appserver +colocated +multicast +infinispan +idempotency +idempotence +selinux +autostart +bugfix +Bugfixes +bugfixes +pidfile +elytron +bugzilla +wfly +iface +mcast +utils +repo +repos +boolean +env +nodeId +Hostname +hostname +pre +readme +varname +zipfile +fixup +restorecon +Overridable +overridable +Systemd +Multicast +Firewalld +Yaml +Config +config diff --git a/README.md b/README.md index 284e822..079110c 100644 --- a/README.md +++ b/README.md @@ -1 +1,116 @@ -# github-actions +# ansible-middleware/github-actions + +Shared GitHub Actions for ansible-middleware collections. + +## Actions + +### `.github/vale-lint` — Vale Prose Lint + +Spell and grammar checking for ansible-middleware collections using [Vale](https://vale.sh/). +It runs two checks in every pull request: + +1. **Prose files** — Vale lints all Markdown and RST files in the collection using the styles configured in the project's `.vale.ini`. +2. **Ansible YAML name fields** — A Python script extracts `name:` fields from task files, handler files, playbooks, and Molecule scenarios and runs them through Vale individually, suppressing rules that are too noisy for short imperative strings. + +#### Usage + +Add a workflow file to your collection repository: + +```yaml +--- +name: Prose Lint + +on: + pull_request: + +jobs: + vale: + name: Spelling and Grammar check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: ansible-middleware/github-actions/.github/vale-lint@main +``` + +You can pin to a specific Vale version or raise the alert threshold: + +```yaml + - uses: ansible-middleware/github-actions/.github/vale-lint@main + with: + vale-version: '3.15.1' + min-alert-level: error +``` + +#### Inputs + +| Input | Description | Default | +|---|---|---| +| `vale-version` | Vale version to install | `3.15.1` | +| `min-alert-level` | Minimum alert level to report (`suggestion`, `warning`, `error`) | `warning` | + +#### Per-project configuration + +Each collection must supply a `.vale.ini` at the repository root. A minimal working example: + +```ini +StylesPath = .github/styles +MinAlertLevel = suggestion +Vocab = Base +Packages = write-good, proselint + +[*.md] +BasedOnStyles = write-good, proselint +Vale.Spelling = warning +proselint.Annotations = NO +TokenIgnores = (?:[a-z][a-z\d]*_)+[a-z\d_]+ + +[*.rst] +BasedOnStyles = write-good, proselint +Vale.Spelling = warning +proselint.Annotations = NO +TokenIgnores = (?:[a-z][a-z\d]*_)+[a-z\d_]+ +``` + +`TokenIgnores` is important: it prevents Vale from spell-checking Ansible `snake_case` variable names inside prose. + +`vale sync` downloads the declared `Packages` at runtime; no vendoring is required. + +#### Vocabulary — shared and per-project + +The action ships a shared vocabulary at `.github/vale-lint/vocabularies/accept.txt` containing ansible-middleware technical terms (WildFly, JBoss, EAP, Keycloak, FQCN, idempotency, etc.). This vocabulary is automatically merged into the collection's `.github/styles/config/vocabularies/Base/accept.txt` during every run. + +To add project-specific accepted terms, create `.github/styles/config/vocabularies/Base/accept.txt` in your collection repository and commit it. One word per line. The action appends the shared vocabulary on top of whatever is already there. + +#### Spell checking — Hunspell dictionary + +For comprehensive spell checking that recognises common English words and proper nouns, configure Vale to use the system Hunspell dictionary (installed by this action): + +```ini +[*.md] +Vale.Spelling.aff = /usr/share/hunspell/en_US.aff +Vale.Spelling.dic = /usr/share/hunspell/en_US.dic +``` + +Without this, Vale falls back to its built-in word list, which may flag common English names. Words genuinely absent from the Hunspell dictionary (middleware product names, Ansible jargon) should go into the project's `accept.txt` instead. + +#### Task-name rule suppressions + +The action ships `.github/vale-lint/vale-tasks.ini`, which suppresses rules that produce too many false positives for short imperative strings (such as `write-good.TooWordy` and `write-good.Weasel`). This config is merged with the project's `.vale.ini` only for the YAML field check — the full prose linting step (`vale .`) is unaffected. + +To override the defaults, create `.github/vale-tasks.ini` in your collection repository. It will take precedence over the shared action config: + +```ini +# .github/vale-tasks.ini — project-specific task name rule tuning +write-good.TooWordy = NO +write-good.Weasel = NO +proselint.LGBTTerms = NO +``` + +#### Files checked + +| Source | Fields / Files | +|---|---| +| Vale | All `*.md` and `*.rst` files (and any other types configured in `.vale.ini`) | +| Python script | `name:`, `msg:`, `fail_msg:`, `success_msg:` in `roles/**/tasks/`, `roles/**/handlers/`, `playbooks/`, and `molecule/` YAML files | +| Python script | `description:`, `short_description:` in `galaxy.yml`, `roles/*/meta/main.yml`, and `roles/*/meta/argument_specs.yml` |