diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..96295ca --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +# Copyright 2026 Hewlett Packard Enterprise Development LP +--- +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/bootstrap-checks.yml b/.github/workflows/bootstrap-checks.yml index 6dc3d80..9ae4ae3 100644 --- a/.github/workflows/bootstrap-checks.yml +++ b/.github/workflows/bootstrap-checks.yml @@ -1,7 +1,8 @@ # Copyright 2026 Hewlett Packard Enterprise Development LP +--- name: bootstrap-validation -on: +'on': pull_request: jobs: @@ -9,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Linux setup prerequisites run: | @@ -40,7 +41,7 @@ jobs: runs-on: windows-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Validate Windows bootstrap prerequisites shell: pwsh diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8595d6f..acbd87e 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -1,7 +1,8 @@ # Copyright 2026 Hewlett Packard Enterprise Development LP +--- name: checks -on: +'on': pull_request: jobs: @@ -10,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -22,6 +23,9 @@ jobs: - name: Block tracked code-checking-ref run: ./checks/guard-code-checking-ref.sh --target-root . + - name: Block active Jenkins @Library reference + run: ./checks/guard-jenkins-library-pin.sh --target-root . + - name: Run linters on changed files env: GITHUB_BASE_REF: ${{ github.base_ref }} diff --git a/.github/workflows/setup-dev-smoke.yml b/.github/workflows/setup-dev-smoke.yml index e305cf4..ad26aef 100644 --- a/.github/workflows/setup-dev-smoke.yml +++ b/.github/workflows/setup-dev-smoke.yml @@ -1,7 +1,8 @@ # Copyright 2026 Hewlett Packard Enterprise Development LP +--- name: setup-dev smoke test -on: +'on': pull_request: jobs: @@ -17,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run setup-dev run: ./bin/setup-dev.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5199fd6..9449f62 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,5 @@ # Copyright 2026 Hewlett Packard Enterprise Development LP +--- repos: - repo: local hooks: diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 90b29b2..e4a3833 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,3 +1,4 @@ +--- # Copyright 2026 Hewlett Packard Enterprise Development LP - id: forbid-code-checking-ref name: forbid tracked code-checking-ref diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..3053ac1 --- /dev/null +++ b/.yamllint @@ -0,0 +1,22 @@ +--- +extends: default + +ignore: | + *.tpl + .cache + *venv/ + LEGACY/** + collections/ansible_collections/** + +rules: + comments-indentation: disable + comments: + min-spaces-from-content: 1 + braces: + max-spaces-inside: 1 + line-length: + max: 120 + allow-non-breakable-inline-mappings: true + octal-values: + forbid-implicit-octal: true + forbid-explicit-octal: true diff --git a/README.md b/README.md index e969b7d..97e474a 100644 --- a/README.md +++ b/README.md @@ -113,9 +113,9 @@ This command syncs the `code_checking` ref, writes the recommended GitHub workflow (`pull_request` trigger only, to avoid duplicate `push` + PR runs), bootstraps or refreshes pre-commit hooks, and updates the consumer `README.md` managed section. It also seeds baseline `.gitignore`, -`cspell.config.yaml`, and `vscode-project-words.txt` in the consumer root when -those files are missing. Running `sync-consumer` means you do not need -to run `setup-github-workflow.sh` separately. +`cspell.config.yaml`, `.yamllint`, and `vscode-project-words.txt` in the +consumer root when those files are missing. Running `sync-consumer` means you +do not need to run `setup-github-workflow.sh` separately. To skip README updates for a specific run: @@ -132,6 +132,7 @@ For an initial consumer-repo integration commit after running - `.pre-commit-config.yaml` (if `setup-dev` was run) - `README.md` - `cspell.config.yaml` (if seeded) +- `.yamllint` (if seeded) - `vscode-project-words.txt` (if seeded) The `code_checking` submodule was previously added. Changes inside that @@ -144,6 +145,7 @@ git add .gitmodules git add .pre-commit-config.yaml # if setup-dev was run git add README.md git add cspell.config.yaml # seeded if missing +git add .yamllint # seeded if missing git add vscode-project-words.txt # seeded if missing ``` diff --git a/bin/ide-workspace-setup.py b/bin/ide-workspace-setup.py index 2131ccc..3e7a888 100755 --- a/bin/ide-workspace-setup.py +++ b/bin/ide-workspace-setup.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 # Copyright 2026 Hewlett Packard Enterprise Development LP +# pylint: disable=invalid-name +"""Generate and apply VS Code workspace settings from YAML inputs.""" import argparse import importlib import json @@ -8,20 +10,23 @@ import subprocess import sys +# Filename is intentionally hyphenated to match existing CLI conventions. + def ensure_yaml_module(): """Return imported PyYAML module, installing it on demand if missing.""" try: return importlib.import_module("yaml") - except ImportError: + except ImportError as exc: if os.name != "nt": raise SystemExit( "Missing dependency: PyYAML. On non-Windows platforms, " "install the distro package first (for example: " "python3-yaml / PyYAML), then rerun setup." - ) + ) from exc print( - "[ide-workspace-setup] PyYAML not found; attempting bootstrap install via pip" + "[ide-workspace-setup] PyYAML not found; attempting bootstrap " + "install via pip" ) install = subprocess.run( [ @@ -32,25 +37,30 @@ def ensure_yaml_module(): "--disable-pip-version-check", "pyyaml", ], + check=False, capture_output=True, text=True, ) if install.returncode != 0: details = "\n".join( - part for part in (install.stdout.strip(), install.stderr.strip()) if part + part + for part in (install.stdout.strip(), install.stderr.strip()) + if part ) raise SystemExit( "Missing dependency: PyYAML and bootstrap install failed. " "Install with 'python -m pip install pyyaml'.\n" + details - ) + ) from exc return importlib.import_module("yaml") yaml = ensure_yaml_module() +# pylint: disable=too-many-locals,too-many-branches,too-many-statements def parse_yaml(path): + """Parse setup YAML and return normalized configuration values.""" result = { "vscode_settings": {}, "vscode_extensions": [], @@ -75,18 +85,31 @@ def parse_yaml(path): if vscode and not isinstance(vscode, dict): raise SystemExit("Invalid YAML: 'ide.vscode' must be a mapping") - settings = vscode.get("settings", {}) if isinstance(vscode, dict) else {} + settings = ( + vscode.get("settings", {}) if isinstance(vscode, dict) else {} + ) if settings and not isinstance(settings, dict): - raise SystemExit("Invalid YAML: 'ide.vscode.settings' must be a mapping") + raise SystemExit( + "Invalid YAML: 'ide.vscode.settings' must be a mapping" + ) result["vscode_settings"] = settings - extensions = vscode.get("extensions", {}) if isinstance(vscode, dict) else {} + extensions = ( + vscode.get("extensions", {}) if isinstance(vscode, dict) else {} + ) if extensions and not isinstance(extensions, dict): - raise SystemExit("Invalid YAML: 'ide.vscode.extensions' must be a mapping") - recommendations = extensions.get("recommendations", []) if isinstance(extensions, dict) else [] + raise SystemExit( + "Invalid YAML: 'ide.vscode.extensions' must be a mapping" + ) + recommendations = ( + extensions.get("recommendations", []) + if isinstance(extensions, dict) + else [] + ) if recommendations and not isinstance(recommendations, list): raise SystemExit( - "Invalid YAML: 'ide.vscode.extensions.recommendations' must be a list" + "Invalid YAML: 'ide.vscode.extensions.recommendations' " + "must be a list" ) result["vscode_extensions"] = recommendations @@ -108,11 +131,14 @@ def parse_yaml(path): pkg_sources = doc.get("packageSources", {}) if pkg_sources: if not isinstance(pkg_sources, dict): - raise SystemExit("Invalid YAML: 'packageSources' must be a mapping") + raise SystemExit( + "Invalid YAML: 'packageSources' must be a mapping" + ) allow_untrusted = pkg_sources.get("allowUncertifiedSources", False) if not isinstance(allow_untrusted, bool): raise SystemExit( - "Invalid YAML: 'packageSources.allowUncertifiedSources' must be boolean" + "Invalid YAML: " + "'packageSources.allowUncertifiedSources' must be boolean" ) allowed_sources = pkg_sources.get("allowedSources", []) if allowed_sources and not isinstance(allowed_sources, list): @@ -129,14 +155,19 @@ def parse_yaml(path): setup_python = setup.get("python", {}) if setup_python: if not isinstance(setup_python, dict): - raise SystemExit("Invalid YAML: 'setup.python' must be a mapping") + raise SystemExit( + "Invalid YAML: 'setup.python' must be a mapping" + ) packages = setup_python.get("packages", []) if packages and not isinstance(packages, list): - raise SystemExit("Invalid YAML: 'setup.python.packages' must be a list") + raise SystemExit( + "Invalid YAML: 'setup.python.packages' must be a list" + ) for pkg in packages: if not isinstance(pkg, str): raise SystemExit( - "Invalid YAML: each 'setup.python.packages' entry must be a string" + "Invalid YAML: each 'setup.python.packages' entry " + "must be a string" ) result["python_packages"] = packages @@ -144,6 +175,7 @@ def parse_yaml(path): def read_json(path): + """Read and parse a UTF-8 JSON file.""" with open(path, "r", encoding="utf-8") as f: return json.load(f) @@ -156,9 +188,17 @@ def deep_merge(base, overlay): - All other values in overlay replace the corresponding base value. """ for key, value in overlay.items(): - if key in base and isinstance(base[key], dict) and isinstance(value, dict): + if ( + key in base + and isinstance(base[key], dict) + and isinstance(value, dict) + ): deep_merge(base[key], value) - elif key in base and isinstance(base[key], list) and isinstance(value, list): + elif ( + key in base + and isinstance(base[key], list) + and isinstance(value, list) + ): existing = base[key] for item in value: if item not in existing: @@ -181,10 +221,10 @@ def normalize_rulers(rulers): if isinstance(entry, int): col = entry if col not in by_column: - by_column[col] = entry # plain int as default + by_column[col] = entry # plain int as default elif isinstance(entry, dict) and "column" in entry: col = entry["column"] - by_column[col] = entry # dict always beats plain int + by_column[col] = entry # dict always beats plain int return [by_column[col] for col in sorted(by_column)] @@ -198,7 +238,10 @@ def canonicalize_shellcheck_settings(settings_obj): """ nested_key = "shellcheck" - if nested_key in settings_obj and isinstance(settings_obj[nested_key], dict): + if ( + nested_key in settings_obj + and isinstance(settings_obj[nested_key], dict) + ): nested = settings_obj[nested_key] for sub_key, sub_value in nested.items(): dotted_key = f"shellcheck.{sub_key}" @@ -213,6 +256,7 @@ def canonicalize_shellcheck_settings(settings_obj): def find_code_cli(): + """Return the VS Code CLI executable path if available.""" if os.name == "nt": code_cmd = shutil.which("code.cmd") if code_cmd: @@ -221,7 +265,7 @@ def find_code_cli(): def ensure_python_packages(package_list, apply): - """Ensure Python packages are installed in the current interpreter env.""" + """Ensure Python packages are installed in the current interpreter.""" seen = set() ordered = [] for pkg in package_list: @@ -236,7 +280,8 @@ def ensure_python_packages(package_list, apply): for pkg in ordered: if os.name != "nt" and pkg.lower() == "pyyaml": print( - "[ide-workspace-setup] non-Windows platform: skipping pip install " + "[ide-workspace-setup] non-Windows platform: " + "skipping pip install " "for pyyaml; prefer distro package management" ) continue @@ -250,15 +295,19 @@ def ensure_python_packages(package_list, apply): "--disable-pip-version-check", pkg, ], + check=False, capture_output=True, text=True, ) if result.returncode != 0: details = "\n".join( - part for part in (result.stdout.strip(), result.stderr.strip()) if part + part + for part in (result.stdout.strip(), result.stderr.strip()) + if part ) print( - f"[ide-workspace-setup] WARNING: failed to install Python package {pkg}: " + "[ide-workspace-setup] WARNING: failed to install " + f"Python package {pkg}: " + details ) else: @@ -267,7 +316,9 @@ def ensure_python_packages(package_list, apply): print(f"[ide-workspace-setup] would ensure Python package: {pkg}") +# pylint: disable=too-many-branches def install_extensions(ext_list, dry_run): + """Install recommended VS Code extensions when running in apply mode.""" code_cmd = find_code_cli() if not code_cmd: print( @@ -285,6 +336,7 @@ def install_extensions(ext_list, dry_run): if not dry_run: result = subprocess.run( [code_cmd, "--list-extensions"], + check=False, capture_output=True, text=True, ) @@ -307,30 +359,35 @@ def install_extensions(ext_list, dry_run): else: result = subprocess.run( [code_cmd, "--install-extension", ext], + check=False, capture_output=True, text=True, ) combined_output = "\n".join( - part for part in (result.stdout.strip(), result.stderr.strip()) if part + part + for part in (result.stdout.strip(), result.stderr.strip()) + if part ) if "already installed" in combined_output.lower(): print(f"[ide-workspace-setup] already installed: {ext}") elif "failed installing extensions" in combined_output.lower(): print( - f"[ide-workspace-setup] WARNING: install failed for {ext}: " + "[ide-workspace-setup] WARNING: install failed for " + f"{ext}: " + combined_output ) elif result.returncode != 0: print( - f"[ide-workspace-setup] WARNING: install failed for {ext}: " + "[ide-workspace-setup] WARNING: install failed for " + f"{ext}: " + combined_output ) else: print(f"[ide-workspace-setup] installed: {ext}") -def ensure_cspell_config(target_root, repo_root, apply): - """Create cspell.config.yaml and project word list if they do not exist.""" +def ensure_cspell_config(target_root, apply): + """Create cspell config and project word list when missing.""" project_words = os.path.join(target_root, "vscode-project-words.txt") cspell_config = os.path.join(target_root, "cspell.config.yaml") @@ -401,13 +458,17 @@ def copy_linter_configs(target_root, repo_root, apply): print(f"[ide-workspace-setup] would copy: {filename}") +# pylint: disable=too-many-locals,too-many-branches,too-many-statements def main(argv): + """Entrypoint for dry-run/apply workspace setup operations.""" parser = argparse.ArgumentParser() parser.add_argument("--apply", action="store_true") parser.add_argument("--config", default="") args = parser.parse_args(argv) - repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) + repo_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), os.pardir) + ) target_root = os.getcwd() default_yaml = os.path.join(target_root, "local_ide_settings.yml") reference_yaml = os.path.join( @@ -422,7 +483,9 @@ def main(argv): cfg = parse_yaml(config_path) if cfg["pre_commit_mode"] not in ("selected", "none", "all"): - raise SystemExit("Invalid pre_commit_mode: expected selected|none|all") + raise SystemExit( + "Invalid pre_commit_mode: expected selected|none|all" + ) baseline_cfg = parse_yaml(reference_yaml) baseline_settings = baseline_cfg["vscode_settings"] @@ -438,7 +501,7 @@ def main(argv): if os.path.exists(out_settings): try: existing_settings = read_json(out_settings) - except Exception: + except (OSError, ValueError, json.JSONDecodeError): print( f"[ide-workspace-setup] WARNING: could not read existing " f"{out_settings}; starting from baseline only" @@ -462,9 +525,10 @@ def main(argv): existing_recommendations = [] if os.path.exists(out_ext): try: - existing_recommendations = read_json( - out_ext).get("recommendations", []) - except Exception: + existing_recommendations = read_json(out_ext).get( + "recommendations", [] + ) + except (OSError, ValueError, json.JSONDecodeError): pass ext_ordered = list(existing_recommendations) @@ -483,7 +547,11 @@ def main(argv): ) print( "[ide-workspace-setup] lint profiles: " - + (", ".join(cfg["lint_profiles"]) if cfg["lint_profiles"] else "(none)") + + ( + ", ".join(cfg["lint_profiles"]) + if cfg["lint_profiles"] + else "(none)" + ) ) print(f"[ide-workspace-setup] pre-commit mode: {cfg['pre_commit_mode']}") @@ -500,14 +568,14 @@ def main(argv): print(f"[ide-workspace-setup] applied settings: {out_settings}") print(f"[ide-workspace-setup] applied extensions: {out_ext}") install_extensions(ext_ordered, dry_run=False) - ensure_cspell_config(target_root, repo_root, apply=True) + ensure_cspell_config(target_root, apply=True) copy_linter_configs(target_root, repo_root, apply=True) else: print("[ide-workspace-setup] DRY RUN (no files written)") print(f"[ide-workspace-setup] would write: {out_settings}") print(f"[ide-workspace-setup] would write: {out_ext}") install_extensions(ext_ordered, dry_run=True) - ensure_cspell_config(target_root, repo_root, apply=False) + ensure_cspell_config(target_root, apply=False) copy_linter_configs(target_root, repo_root, apply=False) print( diff --git a/bin/run-linters.sh b/bin/run-linters.sh index 4b2887b..9fceea3 100755 --- a/bin/run-linters.sh +++ b/bin/run-linters.sh @@ -8,11 +8,12 @@ TARGET_ROOT="$(pwd)" MODE="changed" BASE_REF="${GITHUB_BASE_REF:-}" FIX_MODE=0 +STAGE_MODE=1 usage() { cat <<'EOF' Usage: run-linters.sh [--target-root PATH] [--mode changed|full] \ - [--base-ref REF] [--fix] + [--base-ref REF] [--fix] [--no-stage] Runs applicable linters for the target repository. @@ -21,6 +22,7 @@ Runs applicable linters for the target repository. - In a consumer repository, run this from the consumer root via the submodule path. - --fix enables auto-fix for linters that support it. +- --no-stage suppresses automatic git staging of fixed files. EOF } @@ -42,6 +44,10 @@ while [[ $# -gt 0 ]]; do FIX_MODE=1 shift ;; + --no-stage) + STAGE_MODE=0 + shift + ;; --help|-h) usage exit 0 @@ -61,11 +67,17 @@ echo "[linters] target root: ${TARGET_ROOT}" echo "[linters] mode: ${MODE}" if [[ ${FIX_MODE} -eq 1 ]]; then echo "[linters] fix mode: enabled" + if [[ ${STAGE_MODE} -eq 0 ]]; then + echo "[linters] staging: disabled" + fi fi verify_args=(--target-root "${TARGET_ROOT}") if [[ ${FIX_MODE} -eq 1 ]]; then verify_args+=(--fix) + if [[ ${STAGE_MODE} -eq 0 ]]; then + verify_args+=(--no-stage) + fi fi "${LIB_ROOT}/checks/verify-executable-modes.sh" "${verify_args[@]}" @@ -104,44 +116,94 @@ if [[ -n "${BASE_REF}" ]]; then run_args_common+=(--base-ref "${BASE_REF}") fi +failed_linters=() + for linter in "${REQUIRED_LINTERS[@]}"; do + linter_rc=0 case "${linter}" in shellcheck) run_args=("${run_args_common[@]}") - "${LIB_ROOT}/checks/linters/shellcheck/run.sh" "${run_args[@]}" + if ! "${LIB_ROOT}/checks/linters/shellcheck/run.sh" "${run_args[@]}"; then + linter_rc=$? + fi ;; groovylint) run_args=("${run_args_common[@]}") - "${LIB_ROOT}/checks/linters/groovylint/run.sh" "${run_args[@]}" + if ! "${LIB_ROOT}/checks/linters/groovylint/run.sh" "${run_args[@]}"; then + linter_rc=$? + fi ;; markdownlint) run_args=("${run_args_common[@]}") - "${LIB_ROOT}/checks/linters/markdownlint/run.sh" "${run_args[@]}" + if ! "${LIB_ROOT}/checks/linters/markdownlint/run.sh" "${run_args[@]}"; + then + linter_rc=$? + fi + ;; + yamllint) + run_args=("${run_args_common[@]}") + if ! "${LIB_ROOT}/checks/linters/yamllint/run.sh" "${run_args[@]}"; then + linter_rc=$? + fi ;; python) run_args=("${run_args_common[@]}") - "${LIB_ROOT}/checks/linters/python/run.sh" "${run_args[@]}" + if ! "${LIB_ROOT}/checks/linters/python/run.sh" "${run_args[@]}"; then + linter_rc=$? + fi + ;; + copyright) + run_args=("${run_args_common[@]}") + if [[ ${FIX_MODE} -eq 1 ]]; then + run_args+=(--fix) + if [[ ${STAGE_MODE} -eq 0 ]]; then + run_args+=(--no-stage) + fi + fi + if ! "${LIB_ROOT}/checks/linters/copyright/run.sh" "${run_args[@]}"; then + linter_rc=$? + fi ;; codespell) run_args=("${run_args_common[@]}") - "${LIB_ROOT}/checks/linters/codespell/run.sh" "${run_args[@]}" + if ! "${LIB_ROOT}/checks/linters/codespell/run.sh" "${run_args[@]}"; then + linter_rc=$? + fi ;; text-hygiene) run_args=("${run_args_common[@]}") if [[ ${FIX_MODE} -eq 1 ]]; then run_args+=(--fix) + if [[ ${STAGE_MODE} -eq 0 ]]; then + run_args+=(--no-stage) + fi + fi + if ! "${LIB_ROOT}/checks/linters/text-hygiene/run.sh" "${run_args[@]}"; + then + linter_rc=$? fi - "${LIB_ROOT}/checks/linters/text-hygiene/run.sh" "${run_args[@]}" ;; filename-portability) run_args=("${run_args_common[@]}") - "${LIB_ROOT}/checks/linters/filename-portability/run.sh" "${run_args[@]}" + if ! "${LIB_ROOT}/checks/linters/filename-portability/run.sh" "${run_args[@]}"; + then + linter_rc=$? + fi ;; *) echo "Unknown linter selected: ${linter}" >&2 exit 2 ;; esac + + if [[ ${linter_rc} -ne 0 ]]; then + failed_linters+=("${linter}") + fi done +if [[ ${#failed_linters[@]} -gt 0 ]]; then + echo "[linters] failed linters: ${failed_linters[*]}" >&2 + exit 1 +fi + echo "[linters] complete" diff --git a/bin/setup-dev.sh b/bin/setup-dev.sh index b328ebc..abe920c 100755 --- a/bin/setup-dev.sh +++ b/bin/setup-dev.sh @@ -7,26 +7,42 @@ CODE_CHECKING_PATH="" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LIB_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +escape_sed_replacement() { + printf '%s' "$1" | sed -e 's/[&|]/\\&/g' +} + usage() { echo "Usage: setup-dev.sh [--target-root PATH] [--code-checking-path PATH]" echo - echo "Checks local prerequisites and installs or refreshes pre-commit hooks in the" + echo "Checks local prerequisites and installs or refreshes pre-commit" + echo "hooks in the" echo "target repository." echo echo "Defaults:" echo "- target root: current directory" echo echo "Required for bootstrap:" - echo "- --code-checking-path must be provided when .pre-commit-config.yaml is missing" + echo "- --code-checking-path must be provided when" + echo " .pre-commit-config.yaml is missing" } while [[ $# -gt 0 ]]; do case "$1" in --target-root) + if [[ $# -lt 2 || -z "${2}" || "${2}" == --* ]]; then + echo "Missing value for $1" >&2 + usage >&2 + exit 2 + fi TARGET_ROOT="$2" shift 2 ;; --code-checking-path) + if [[ $# -lt 2 || -z "${2}" || "${2}" == --* ]]; then + echo "Missing value for $1" >&2 + usage >&2 + exit 2 + fi CODE_CHECKING_PATH="$2" shift 2 ;; @@ -53,8 +69,17 @@ create_pre_commit_config_if_missing() { echo "[setup-dev] no .pre-commit-config.yaml found. Creating..." if [[ -z "${CODE_CHECKING_PATH}" ]]; then - echo "[setup-dev] unable to locate code_checking path from ${TARGET_ROOT}" >&2 - echo "[setup-dev] provide --code-checking-path (for example: code_checking or .)" >&2 + # Auto-detect when invoked via a vendored code_checking submodule. + if [[ "${LIB_ROOT}/" == "${TARGET_ROOT}/"* ]]; then + CODE_CHECKING_PATH="${LIB_ROOT#"${TARGET_ROOT}/"}" + fi + fi + + if [[ -z "${CODE_CHECKING_PATH}" ]]; then + echo "[setup-dev] unable to locate code_checking path from" \ + "${TARGET_ROOT}" >&2 + echo "[setup-dev] provide --code-checking-path" \ + "(for example: code_checking or .)" >&2 return 1 fi @@ -68,27 +93,18 @@ create_pre_commit_config_if_missing() { hook_prefix="./${code_checking_path}" fi - { - printf '%s\n' 'repos:' - printf '%s\n' ' - repo: local' - printf '%s\n' ' hooks:' - printf '%s\n' ' - id: forbid-code-checking-ref' - printf '%s\n' ' name: forbid tracked code-checking-ref' - printf '%s\n' " entry: ${hook_prefix}/checks/guard-code-checking-ref.sh --target-root ." - printf '%s\n' ' language: script' - printf '%s\n' ' pass_filenames: false' - printf '%s\n' ' always_run: true' - printf '%s\n' ' stages: [commit]' - printf '%s\n' ' require_serial: true' - printf '%s\n' ' - id: shellcheck' - printf '%s\n' ' name: shellcheck' - printf '%s\n' " entry: ${hook_prefix}/bin/run-linters.sh --mode changed --target-root ." - printf '%s\n' ' language: script' - printf '%s\n' ' pass_filenames: false' - printf '%s\n' ' types: [shell]' - printf '%s\n' ' stages: [commit]' - printf '%s\n' ' require_serial: false' - } > "${config_path}" + local template_path="${LIB_ROOT}/checks/pre-commit_d/" + template_path+="pre-commit-config.template.yaml" + if [[ ! -f "${template_path}" ]]; then + echo "[setup-dev] missing template: ${template_path}" >&2 + return 1 + fi + + local escaped_hook_prefix="" + escaped_hook_prefix="$(escape_sed_replacement "${hook_prefix}")" + + sed "s|__HOOK_PREFIX__|${escaped_hook_prefix}|g" \ + "${template_path}" > "${config_path}" echo "[setup-dev] created .pre-commit-config.yaml using ${hook_prefix} hooks" } @@ -124,7 +140,8 @@ install_python_package() { ;; # On non-Linux systems, allow user-scoped pip fallback (macOS, etc.) *) - if [[ "$(uname -s)" == "Darwin" ]] && command -v brew >/dev/null 2>&1; then + if [[ "$(uname -s)" == "Darwin" ]] && command -v brew >/dev/null 2>&1; + then echo "[setup-dev] installing $pkg_name via homebrew..." brew install "$pkg_name" return $? @@ -132,7 +149,8 @@ install_python_package() { if [[ "$(id -u)" -eq 0 ]]; then echo "[setup-dev] refusing to install $pkg_name with pip as root" >&2 - echo "[setup-dev] use system package manager when possible; otherwise rerun as non-root" >&2 + echo "[setup-dev] use system package manager when possible;" \ + "otherwise rerun as non-root" >&2 return 1 fi if command -v python3 >/dev/null 2>&1; then @@ -148,7 +166,8 @@ install_python_package() { pip install --user "$pkg_name" return $? else - echo "[setup-dev] no supported automatic installer found; install $pkg_name manually" >&2 + echo "[setup-dev] no supported automatic installer found;" \ + "install $pkg_name manually" >&2 return 1 fi ;; @@ -172,14 +191,16 @@ if ! "${LIB_ROOT}/checks/install-linter-tools.sh" \ --target-root "${TARGET_ROOT}" \ --mode full; then echo "[setup-dev] note: unable to auto-install one or more linter tools" >&2 - echo "[setup-dev] continuing; lint checks may fail until tools are installed" >&2 + echo "[setup-dev] continuing; lint checks may fail until tools are" \ + "installed" >&2 fi if ! create_pre_commit_config_if_missing; then exit 1 fi -if ! git -C "${TARGET_ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then +if ! git -C "${TARGET_ROOT}" rev-parse --is-inside-work-tree \ + >/dev/null 2>&1; then echo "[setup-dev] target is not a git repository: ${TARGET_ROOT}" >&2 exit 1 fi diff --git a/bin/setup-github-workflow.sh b/bin/setup-github-workflow.sh index a60aa2f..d9622e6 100755 --- a/bin/setup-github-workflow.sh +++ b/bin/setup-github-workflow.sh @@ -6,6 +6,28 @@ TARGET_ROOT="$(pwd)" SUBMODULE_PATH="code_checking" WORKFLOW_RELATIVE_PATH=".github/workflows/basic-source-checks.yml" MODE="check" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +escape_sed_replacement() { + printf '%s' "$1" | sed -e 's/[&|]/\\&/g' +} + +render_workflow_yaml() { + local code_checking_path="$1" + local template_path="${SCRIPT_DIR}/../checks/workflow_d/" + template_path+="basic-source-checks.template.yml" + + if [[ ! -f "${template_path}" ]]; then + printf '%s\n' "[setup-github-workflow] missing template:" \ + "${template_path}" >&2 + return 1 + fi + + local escaped_code_checking_path="" + escaped_code_checking_path="$(escape_sed_replacement "${code_checking_path}")" + sed "s|__CODE_CHECKING_PATH__|${escaped_code_checking_path}|g" \ + "${template_path}" +} usage() { cat <<'EOF' @@ -52,107 +74,21 @@ done TARGET_ROOT="$(cd "${TARGET_ROOT}" && pwd)" WORKFLOW_PATH="${TARGET_ROOT}/${WORKFLOW_RELATIVE_PATH}" - -# SC2016: single-quoted strings below are intentional YAML literal content, -# not shell expansions. -# SC1003: trailing backslashes in single-quoted strings are YAML line -# continuations, not escape attempts. -# shellcheck disable=SC2016,SC1003 -build_workflow_yaml() { - local code_checking_path="$1" - - printf '%s\n' 'name: basic-source-checks' - printf '%s\n' '' - printf '%s\n' 'on:' - printf '%s\n' ' pull_request:' - printf '%s\n' '' - printf '%s\n' 'jobs:' - printf '%s\n' ' basic-source-checks:' - printf '%s\n' ' name: Basic Source checks' - printf '%s\n' ' runs-on: ubuntu-latest' - printf '%s\n' ' steps:' - printf '%s\n' ' - name: Checkout' - printf '%s\n' ' uses: actions/checkout@v5' - printf '%s\n' ' with:' - printf '%s\n' ' submodules: recursive' - printf '%s\n' ' fetch-depth: 0' - printf '%s\n' '' - printf '%s\n' ' - name: Resolve code_checking ref' - printf '%s\n' ' run: |' - printf '%s\n' ' REF="origin/main"' - printf '%s\n' ' if [ -f code-checking-ref ]; then' - printf '%s\n' ' REF="$(' - printf '%s\n' " grep -v '^[[:space:]]*#' code-checking-ref \\" - printf '%s\n' " | sed '/^[[:space:]]*$/d' \\" - printf '%s\n' ' | head -n 1' - printf '%s\n' ' )"' - printf '%s\n' ' fi' - printf '%s\n' ' if [ -z "${REF}" ]; then' - printf '%s\n' ' REF="origin/main"' - printf '%s\n' ' fi' - printf '%s\n' ' case "${REF}" in' - printf '%s\n' ' refs/*)' - printf '%s\n' ' FETCH_REF="${REF}"' - printf '%s\n' ' ;;' - printf '%s\n' ' origin/*)' - printf '%s\n' ' FETCH_REF="refs/heads/${REF#origin/}"' - printf '%s\n' ' ;;' - printf '%s\n' ' pull/*/head|pull/*/merge)' - printf '%s\n' ' FETCH_REF="refs/${REF}"' - printf '%s\n' ' ;;' - printf '%s\n' ' *)' - printf '%s\n' ' FETCH_REF="refs/heads/${REF}"' - printf '%s\n' ' ;;' - printf '%s\n' ' esac' - printf '%s\n' " git -C ./${code_checking_path} fetch origin \"\${FETCH_REF}\"" - printf '%s\n' " git -C ./${code_checking_path} checkout FETCH_HEAD" - printf '%s\n' ' echo "[workflow] using code_checking ref: ${REF}"' - printf '%s\n' '' - printf '%s\n' ' - name: Install linter tools' - printf '%s\n' ' env:' - printf '%s\n' ' GITHUB_BASE_REF: ${{ github.base_ref }}' - printf '%s\n' ' run: |' - printf '%s\n' " ./${code_checking_path}/checks/install-linter-tools.sh \\" - printf '%s\n' " --library-root ./${code_checking_path} \\" - printf '%s\n' ' --target-root . \' - printf '%s\n' ' --mode changed \' - printf '%s\n' ' --base-ref "${GITHUB_BASE_REF:-}"' - printf '%s\n' '' - printf '%s\n' ' - name: Block tracked code-checking-ref' - printf '%s\n' ' id: guard_code_checking_ref' - printf '%s\n' ' continue-on-error: true' - printf '%s\n' ' run: |' - printf '%s\n' " ./${code_checking_path}/checks/guard-code-checking-ref.sh \\" - printf '%s\n' ' --target-root .' - printf '%s\n' '' - printf '%s\n' ' - name: Run linters on changed files' - printf '%s\n' ' env:' - printf '%s\n' ' GITHUB_BASE_REF: ${{ github.base_ref }}' - printf '%s\n' " run: ./${code_checking_path}/bin/run-linters.sh" - printf '%s\n' '' - printf '%s\n' ' - name: Fail if code-checking-ref is tracked' - printf '%s\n' ' if: >-' - printf '%s\n' ' ${{ always() &&' - printf '%s\n' " steps.guard_code_checking_ref.outcome == 'failure' }}" - printf '%s\n' ' run: |' - printf '%s\n' ' echo "[workflow] code-checking-ref was tracked in this change" >&2' - printf '%s\n' ' echo "[workflow] keeping the final job status failed after" >&2' - printf '%s\n' ' echo "[workflow] running the remaining checks" >&2' - printf '%s\n' ' exit 1' -} - -EXPECTED_CONTENT="$(build_workflow_yaml "${SUBMODULE_PATH}")" +TMP_EXPECTED="$(mktemp)" +trap 'rm -f "${TMP_EXPECTED}"' EXIT +render_workflow_yaml "${SUBMODULE_PATH}" > "${TMP_EXPECTED}" if [[ "${MODE}" == "check" ]]; then if [[ ! -f "${WORKFLOW_PATH}" ]]; then - printf '%s\n' "[setup-github-workflow] missing workflow: ${WORKFLOW_RELATIVE_PATH}" >&2 + printf '%s\n' "[setup-github-workflow] missing workflow:" \ + "${WORKFLOW_RELATIVE_PATH}" >&2 printf '%s\n' "[setup-github-workflow] run with --apply to create it" >&2 exit 1 fi - CURRENT_CONTENT="$(cat "${WORKFLOW_PATH}")" - if [[ "${CURRENT_CONTENT}" != "${EXPECTED_CONTENT}" ]]; then - printf '%s\n' "[setup-github-workflow] workflow differs from recommended content" >&2 + if ! cmp -s "${WORKFLOW_PATH}" "${TMP_EXPECTED}"; then + printf '%s\n' "[setup-github-workflow] workflow differs from" \ + "recommended content" >&2 printf '%s\n' "[setup-github-workflow] file: ${WORKFLOW_RELATIVE_PATH}" >&2 printf '%s\n' "[setup-github-workflow] run with --apply to update it" >&2 exit 1 @@ -163,5 +99,5 @@ if [[ "${MODE}" == "check" ]]; then fi mkdir -p "$(dirname "${WORKFLOW_PATH}")" -printf '%s\n' "${EXPECTED_CONTENT}" > "${WORKFLOW_PATH}" +cp "${TMP_EXPECTED}" "${WORKFLOW_PATH}" printf '%s\n' "[setup-github-workflow] wrote ${WORKFLOW_RELATIVE_PATH}" diff --git a/bin/sync-consumer.sh b/bin/sync-consumer.sh index 4666964..786b7d8 100755 --- a/bin/sync-consumer.sh +++ b/bin/sync-consumer.sh @@ -87,14 +87,16 @@ done TARGET_ROOT="$(cd "${TARGET_ROOT}" && pwd)" -if ! git -C "${TARGET_ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then +if ! git -C "${TARGET_ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1 +then echo "[sync-consumer] target is not a git repository: ${TARGET_ROOT}" >&2 exit 2 fi if [[ "${LIB_ROOT}" == "${TARGET_ROOT}" ]]; then - echo "[sync-consumer] target root is the code_checking repository itself" >&2 - echo "[sync-consumer] run this from a consumer repository that vendors code_checking as a submodule" >&2 + echo "[sync-consumer] target root is the code_checking repository" >&2 + echo "[sync-consumer] run this from a consumer repository that" >&2 + echo "[sync-consumer] vendors code_checking as a submodule" >&2 exit 2 fi @@ -140,7 +142,8 @@ resolve_ref() { elif [[ "${desired_ref}" == origin/* ]]; then local branch="${desired_ref#origin/}" candidates+=("refs/heads/${branch}" "${branch}") - elif [[ "${desired_ref}" == pull/*/head || "${desired_ref}" == pull/*/merge ]]; then + elif [[ "${desired_ref}" == pull/*/head || "${desired_ref}" == pull/*/merge ]] + then candidates+=("refs/${desired_ref}" "${desired_ref}") else candidates+=("refs/heads/${desired_ref}" "${desired_ref}") @@ -149,7 +152,9 @@ resolve_ref() { local candidate="" local out="" for candidate in "${candidates[@]}"; do - if out="$(git -C "${LIB_ROOT}" ls-remote --exit-code origin "${candidate}" 2>/dev/null)"; then + if out="$(git -C "${LIB_ROOT}" ls-remote --exit-code \ + origin "${candidate}" 2>/dev/null)" + then desired_sha="$(awk 'NR==1 {print $1}' <<< "${out}")" resolved_from="${candidate}" [[ -n "${desired_sha}" ]] && break @@ -163,17 +168,21 @@ resolve_ref() { printf '%s\n%s\n' "${desired_sha}" "${resolved_from}" } -mapfile -t resolved_info < <(resolve_ref "${DESIRED_REF}") || { - echo "[sync-consumer] unable to resolve desired ref '${DESIRED_REF}' from origin" >&2 +mapfile -t resolved_info < <(resolve_ref "${DESIRED_REF}") + +if [[ ${#resolved_info[@]} -lt 2 ]] || [[ -z "${resolved_info[0]}" ]]; then + echo "[sync-consumer] unable to resolve ref '${DESIRED_REF}'" >&2 + echo "[sync-consumer] from origin" >&2 exit 1 -} +fi DESIRED_SHA="${resolved_info[0]}" RESOLVED_FROM="${resolved_info[1]}" CURRENT_SHA="$(git -C "${LIB_ROOT}" rev-parse HEAD)" if [[ "${CURRENT_SHA}" != "${DESIRED_SHA}" ]]; then - echo "[sync-consumer] syncing ${SUBMODULE_PATH} to ${DESIRED_REF} (${RESOLVED_FROM})" + echo "[sync-consumer] syncing ${SUBMODULE_PATH}" \ + "to ${DESIRED_REF} (${RESOLVED_FROM})" if [[ "${DESIRED_REF}" =~ ^[0-9a-fA-F]{7,40}$ ]]; then git -C "${LIB_ROOT}" fetch origin "${DESIRED_REF}" git -C "${LIB_ROOT}" checkout "${DESIRED_REF}" @@ -182,7 +191,8 @@ if [[ "${CURRENT_SHA}" != "${DESIRED_SHA}" ]]; then git -C "${LIB_ROOT}" checkout FETCH_HEAD fi else - echo "[sync-consumer] ${SUBMODULE_PATH} already matches ${DESIRED_REF} (${CURRENT_SHA:0:12})" + echo "[sync-consumer] ${SUBMODULE_PATH} matches" \ + "${DESIRED_REF} (${CURRENT_SHA:0:12})" fi if [[ "${REFRESH_WORKFLOW}" == true ]]; then @@ -204,7 +214,8 @@ if [[ "${REFRESH_PRE_COMMIT}" == true ]]; then echo "[sync-consumer] pre-commit not installed; skipping hook refresh" >&2 fi else - echo "[sync-consumer] no .pre-commit-config.yaml in target root; skipping hook refresh" + echo "[sync-consumer] no .pre-commit-config.yaml in target" >&2 + echo "[sync-consumer] root; skipping hook refresh" >&2 fi fi @@ -218,7 +229,8 @@ if [[ "${UPDATE_README}" == true ]]; then This repository uses the shared \`code_checking\` submodule. - Framework documentation: [code_checking README](./${SUBMODULE_PATH}/README.md) -- Integration guide: [code_checking integration](./${SUBMODULE_PATH}/docs/integration.md) +- Integration guide: + [code_checking integration](./${SUBMODULE_PATH}/docs/integration.md) ${END_MARKER}" @@ -267,17 +279,29 @@ if [[ ! -f "${TARGET_ROOT}/cspell.config.yaml" ]]; then CSPELL_CONFIG_BASELINE="${LIB_ROOT}/cspell.config.yaml" if [[ -f "${CSPELL_CONFIG_BASELINE}" ]]; then cp "${CSPELL_CONFIG_BASELINE}" "${TARGET_ROOT}/cspell.config.yaml" - echo "[sync-consumer] created cspell.config.yaml from code_checking baseline" + echo "[sync-consumer] created cspell.config.yaml" \ + "from code_checking baseline" else echo "[sync-consumer] baseline not found: ${CSPELL_CONFIG_BASELINE}" >&2 fi fi +if [[ ! -f "${TARGET_ROOT}/.yamllint" ]]; then + YAMLLINT_BASELINE="${LIB_ROOT}/.yamllint" + if [[ -f "${YAMLLINT_BASELINE}" ]]; then + cp "${YAMLLINT_BASELINE}" "${TARGET_ROOT}/.yamllint" + echo "[sync-consumer] created .yamllint from code_checking baseline" + else + echo "[sync-consumer] baseline not found: ${YAMLLINT_BASELINE}" >&2 + fi +fi + if [[ ! -f "${TARGET_ROOT}/vscode-project-words.txt" ]]; then CSPELL_WORDS_BASELINE="${LIB_ROOT}/vscode-project-words.txt" if [[ -f "${CSPELL_WORDS_BASELINE}" ]]; then cp "${CSPELL_WORDS_BASELINE}" "${TARGET_ROOT}/vscode-project-words.txt" - echo "[sync-consumer] created vscode-project-words.txt from code_checking baseline" + echo "[sync-consumer] created vscode-project-words.txt" \ + "from code_checking baseline" else echo "[sync-consumer] baseline not found: ${CSPELL_WORDS_BASELINE}" >&2 fi diff --git a/checks/detect-linters.sh b/checks/detect-linters.sh index 38e820e..50a98f5 100755 --- a/checks/detect-linters.sh +++ b/checks/detect-linters.sh @@ -13,7 +13,9 @@ linter_require_common_args shellcheck_needed=0 groovylint_needed=0 markdownlint_needed=0 +yamllint_needed=0 python_needed=0 +copyright_needed=0 codespell_needed=0 text_hygiene_needed=0 filename_portability_needed=0 @@ -36,9 +38,17 @@ while IFS= read -r file_path; do markdownlint_needed=1 fi + if linter_is_yaml_candidate "${file_path}"; then + yamllint_needed=1 + fi + if linter_is_python_candidate "${file_path}"; then python_needed=1 fi + + if linter_is_copyright_candidate "${file_path}"; then + copyright_needed=1 + fi done < <(linter_get_candidate_files_acmr) if [[ ${shellcheck_needed} -eq 1 ]]; then @@ -50,9 +60,15 @@ fi if [[ ${markdownlint_needed} -eq 1 ]]; then echo 'markdownlint' fi +if [[ ${yamllint_needed} -eq 1 ]]; then + echo 'yamllint' +fi if [[ ${python_needed} -eq 1 ]]; then echo 'python' fi +if [[ ${copyright_needed} -eq 1 ]]; then + echo 'copyright' +fi if [[ ${codespell_needed} -eq 1 ]]; then echo 'codespell' fi diff --git a/checks/guard-jenkins-library-pin.sh b/checks/guard-jenkins-library-pin.sh new file mode 100755 index 0000000..2f54ed7 --- /dev/null +++ b/checks/guard-jenkins-library-pin.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Copyright 2026 Hewlett Packard Enterprise Development LP +set -euo pipefail + +TARGET_ROOT="" + +usage() { + cat <<'EOF' +Usage: guard-jenkins-library-pin.sh --target-root PATH + +Fails when an active Jenkins shared-library reference is present, such as: + @Library("my-shared-lib") _ + +Rationale: +- live @Library references are for PR/testing workflows only +- @Library references must not be landed in mergeable branches +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --target-root) + TARGET_ROOT="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "${TARGET_ROOT}" ]]; then + usage >&2 + exit 2 +fi + +TARGET_ROOT="$(cd "${TARGET_ROOT}" && pwd)" + +if ! git -C "${TARGET_ROOT}" rev-parse --is-inside-work-tree \ + >/dev/null 2>&1; then + echo "[jenkins-library-pin-guard] target is not a git working tree:" >&2 + echo "[jenkins-library-pin-guard] ${TARGET_ROOT}" >&2 + exit 2 +fi + +mapfile -t JENKINS_FILES < <( + git -C "${TARGET_ROOT}" ls-files -- 'Jenkinsfile*' '*/Jenkinsfile*' +) + +if [[ ${#JENKINS_FILES[@]} -eq 0 ]]; then + echo "[jenkins-library-pin-guard] ok: no Jenkinsfile candidates found" + exit 0 +fi + +library_regex='@Library[[:space:]]*\(' + +guard_failed=0 +for rel_path in "${JENKINS_FILES[@]}"; do + abs_path="${TARGET_ROOT}/${rel_path}" + [[ -f "${abs_path}" ]] || continue + + mapfile -t library_hits < <( + LC_ALL=C awk -v pattern="${library_regex}" ' + { + line = $0 + + # Ignore single-line comments and block-comment boundaries. + if (line ~ /^[[:space:]]*\/\//) { + next + } + if (line ~ /^[[:space:]]*\/\*/) { + in_block_comment = 1 + next + } + if (in_block_comment) { + if (line ~ /\*\//) { + in_block_comment = 0 + } + next + } + + if (line ~ pattern) { + print NR ":" line + } + } + ' "${abs_path}" + ) + + if [[ ${#library_hits[@]} -gt 0 ]]; then + guard_failed=1 + echo "[jenkins-library-pin-guard] blocked: active @Library reference in ${rel_path}" >&2 + printf '%s\n' "${library_hits[@]}" >&2 + fi +done + +if [[ ${guard_failed} -ne 0 ]]; then + echo "[jenkins-library-pin-guard] remove active @Library references" >&2 + echo "[jenkins-library-pin-guard] before landing to mergeable branches" >&2 + exit 1 +fi + +echo "[jenkins-library-pin-guard] ok: no active @Library references found" diff --git a/checks/install-linter-tools.sh b/checks/install-linter-tools.sh index 68e0c0f..71b224d 100755 --- a/checks/install-linter-tools.sh +++ b/checks/install-linter-tools.sh @@ -250,6 +250,7 @@ for linter in "${REQUIRED_LINTERS[@]}"; do shellcheck) PACKAGES+=("shellcheck") ;; groovylint) PACKAGES+=("npm") ;; markdownlint) PACKAGES+=("npm") ;; + yamllint) PACKAGES+=("yamllint") ;; python) PACKAGES+=("flake8" "pylint") ;; codespell) PACKAGES+=("codespell") ;; *) continue ;; @@ -377,7 +378,7 @@ fi if printf '%s\n' "${REQUIRED_LINTERS[@]}" | grep -Fxq 'groovylint'; then INSTALLED_GROOVY_LINT_VERSION="" if command -v npm-groovy-lint >/dev/null 2>&1 && ensure_groovylint_command_works; then - INSTALLED_GROOVY_LINT_VERSION="$({ npm-groovy-lint --version || true; } \ + INSTALLED_GROOVY_LINT_VERSION="$({ npm-groovy-lint --version 2>/dev/null || true; } \ | awk '/npm-groovy-lint version/{print $NF; exit}')" fi diff --git a/checks/linter-common.sh b/checks/linter-common.sh index bf9724a..8d0ccec 100755 --- a/checks/linter-common.sh +++ b/checks/linter-common.sh @@ -66,8 +66,11 @@ linter_fail_on_unknown_args() { linter_get_candidate_files_acmr() { if [[ "${MODE}" == "full" ]]; then - # Normalize `find` output to match git path style (no leading `./`). - (cd "${TARGET_ROOT}" && find . -type f -print | sed 's#^./##') + { + cd "${TARGET_ROOT}" || return + git ls-files + git ls-files --others --exclude-standard + } | sort -u return fi @@ -123,7 +126,8 @@ linter_should_skip_candidate_path() { local file_path="$1" [[ -z "${file_path}" ]] && return 0 - if [[ -n "${LIB_RELATIVE_PATH}" && "${file_path}" == "${LIB_RELATIVE_PATH}"/* ]]; then + if [[ -n "${LIB_RELATIVE_PATH}" && + "${file_path}" == "${LIB_RELATIVE_PATH}"/* ]]; then return 0 fi @@ -135,6 +139,9 @@ linter_is_shell_script_candidate() { local absolute_path="${TARGET_ROOT}/${file_path}" local base_name="${file_path##*/}" local first_line="" + local shell_shebang_regex='^#![[:space:]]*([^[:space:]]+/)?' + shell_shebang_regex+='(env([[:space:]]+-S)?[[:space:]]+)?' + shell_shebang_regex+='(bash|sh|dash|ksh|zsh)([[:space:]]|$)' [[ -f "${absolute_path}" ]] || return 1 @@ -149,8 +156,8 @@ linter_is_shell_script_candidate() { # Files without an extension may declare a shell interpreter via a shebang. IFS= read -r first_line < "${absolute_path}" || true - if printf '%s\n' "${first_line}" | LC_ALL=C grep -Eq \ - '^#![[:space:]]*([^[:space:]]+/)?(env([[:space:]]+-S)?[[:space:]]+)?(bash|sh|dash|ksh|zsh)([[:space:]]|$)'; then + if printf '%s\n' "${first_line}" | LC_ALL=C grep -Eq "${shell_shebang_regex}" + then return 0 fi @@ -188,10 +195,28 @@ linter_is_markdown_candidate() { return 1 } +linter_is_yaml_candidate() { + local file_path="$1" + local absolute_path="${TARGET_ROOT}/${file_path}" + + [[ -f "${absolute_path}" ]] || return 1 + + case "${file_path}" in + *.yml|*.yaml|.yamllint|.ansible-lint) + return 0 + ;; + esac + + return 1 +} + linter_is_python_candidate() { local file_path="$1" local absolute_path="${TARGET_ROOT}/${file_path}" local first_line="" + local python_shebang_regex='^#![[:space:]]*([^[:space:]]+/)?' + python_shebang_regex+='(env([[:space:]]+-S)?[[:space:]]+)?' + python_shebang_regex+='python([[:space:]]|$)' [[ -f "${absolute_path}" ]] || return 1 @@ -209,8 +234,39 @@ linter_is_python_candidate() { # Files without an extension may declare a Python interpreter via a shebang. IFS= read -r first_line < "${absolute_path}" || true - if printf '%s\n' "${first_line}" | LC_ALL=C grep -Eq \ - '^#![[:space:]]*([^[:space:]]+/)?(env([[:space:]]+-S)?[[:space:]]+)?python([[:space:]]|$)'; then + if printf '%s\n' "${first_line}" | LC_ALL=C grep -Eq "${python_shebang_regex}" + then + return 0 + fi + + return 1 +} + +linter_is_copyright_candidate() { + # Scope: program source files only (shell, Python, PowerShell). + # + # YAML and XML are intentionally excluded. The policy given was to apply + # copyright notices to program source, not configuration files. Whether + # Ansible YAML files (which are closer to source than configuration) and + # XML configuration files should carry notices is an open question pending + # a management ruling. Extend this function and update docs/linters.md once + # that decision is made. + local file_path="$1" + local absolute_path="${TARGET_ROOT}/${file_path}" + + [[ -f "${absolute_path}" ]] || return 1 + + case "${file_path}" in + *.sh|*.ps1|*.psm1|*.psd1|*.py) + return 0 + ;; + esac + + # Files without an extension may still declare supported script types. + if linter_is_shell_script_candidate "${file_path}"; then + return 0 + fi + if linter_is_python_candidate "${file_path}"; then return 0 fi diff --git a/checks/linters/copyright/run.ps1 b/checks/linters/copyright/run.ps1 new file mode 100644 index 0000000..84ca5b7 --- /dev/null +++ b/checks/linters/copyright/run.ps1 @@ -0,0 +1,9 @@ +# Copyright 2026 Hewlett Packard Enterprise Development LP +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $PSCommandPath +$checksRoot = Split-Path -Parent (Split-Path -Parent $scriptDir) + +. (Join-Path $checksRoot 'invoke-bash.ps1') +Invoke-BashScript -ScriptPath (Join-Path $scriptDir 'run.sh') -ScriptArgs $args diff --git a/checks/linters/copyright/run.sh b/checks/linters/copyright/run.sh new file mode 100755 index 0000000..9817446 --- /dev/null +++ b/checks/linters/copyright/run.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Copyright 2026 Hewlett Packard Enterprise Development LP +set -euo pipefail + +FIX_MODE=0 +STAGE_MODE=1 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/../../linter-common.sh" + +linter_parse_common_args "$@" +for arg in "${LINTER_REMAINING_ARGS[@]}"; do + case "${arg}" in + --fix) + FIX_MODE=1 + ;; + --no-stage) + STAGE_MODE=0 + ;; + *) + echo "Unknown argument: ${arg}" >&2 + exit 2 + ;; + esac +done +linter_require_common_args + +YEAR_TOKEN='[0-9]{4}(-[0-9]{4})?' +NOTICE_PATTERN="^# Copyright ${YEAR_TOKEN}([[:space:]]*,[[:space:]]*${YEAR_TOKEN})*" +NOTICE_PATTERN+=" Hewlett Packard Enterprise Development LP$" +NOTICE_LINE="# Copyright $(date +%Y) Hewlett Packard Enterprise Development LP" + +files_to_check=() +while IFS= read -r file_path; do + linter_should_skip_candidate_path "${file_path}" && continue + + if linter_is_copyright_candidate "${file_path}"; then + files_to_check+=("${file_path}") + fi +done < <(linter_get_candidate_files_acmr) + +if [[ ${#files_to_check[@]} -eq 0 ]]; then + echo "[copyright] no candidate files to lint" + exit 0 +fi + +has_failures=0 +checked_count=0 + +for file_path in "${files_to_check[@]}"; do + absolute_path="${TARGET_ROOT}/${file_path}" + [[ -f "${absolute_path}" ]] || continue + + checked_count=$((checked_count + 1)) + + if head -n 5 "${absolute_path}" | LC_ALL=C grep -Eq "${NOTICE_PATTERN}"; then + continue + fi + + if [[ ${FIX_MODE} -eq 1 ]]; then + tmp_file="$(mktemp)" + first_line='' + IFS= read -r first_line < "${absolute_path}" || true + + if [[ "${first_line}" == '#!'* ]]; then + { + sed -n '1p' "${absolute_path}" + printf '%s\n' "${NOTICE_LINE}" + sed -n '2,$p' "${absolute_path}" + } > "${tmp_file}" + else + { + printf '%s\n' "${NOTICE_LINE}" + cat "${absolute_path}" + } > "${tmp_file}" + fi + + mv "${tmp_file}" "${absolute_path}" + if [[ ${STAGE_MODE} -eq 1 ]]; then + git -C "${TARGET_ROOT}" add -- "${file_path}" + fi + echo "[copyright] fixed header: ${file_path}" + else + echo "[copyright] missing header: ${file_path}" >&2 + has_failures=1 + fi +done + +if [[ ${checked_count} -eq 0 ]]; then + echo "[copyright] no candidate files to lint" + exit 0 +fi + +if [[ ${has_failures} -ne 0 ]]; then + exit 1 +fi + +echo "[copyright] checked ${checked_count} file(s)" diff --git a/checks/linters/python/run.sh b/checks/linters/python/run.sh index bd41f8f..896cf0d 100755 --- a/checks/linters/python/run.sh +++ b/checks/linters/python/run.sh @@ -34,5 +34,18 @@ if [[ ${#files_to_check[@]} -eq 0 ]]; then fi echo "[python] linting ${#files_to_check[@]} file(s)" -flake8 "${files_to_check[@]}" -pylint "${files_to_check[@]}" + +flake8_rc=0 +pylint_rc=0 + +if ! flake8 "${files_to_check[@]}"; then + flake8_rc=$? +fi + +if ! pylint "${files_to_check[@]}"; then + pylint_rc=$? +fi + +if [[ ${flake8_rc} -ne 0 || ${pylint_rc} -ne 0 ]]; then + exit 1 +fi diff --git a/checks/linters/text-hygiene/run.sh b/checks/linters/text-hygiene/run.sh index 404a485..c5354f6 100755 --- a/checks/linters/text-hygiene/run.sh +++ b/checks/linters/text-hygiene/run.sh @@ -3,6 +3,7 @@ set -euo pipefail FIX_MODE=0 +STAGE_MODE=1 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "${SCRIPT_DIR}/../../linter-common.sh" @@ -13,6 +14,9 @@ for arg in "${LINTER_REMAINING_ARGS[@]}"; do --fix) FIX_MODE=1 ;; + --no-stage) + STAGE_MODE=0 + ;; *) echo "Unknown argument: ${arg}" >&2 exit 2 @@ -41,7 +45,9 @@ while IFS= read -r file_path; do if grep -nE '[[:blank:]]+$' "${absolute_path}" >/dev/null; then if [[ ${FIX_MODE} -eq 1 ]]; then sed -i 's/[[:blank:]]\+$//' "${absolute_path}" - git -C "${TARGET_ROOT}" add -- "${file_path}" + if [[ ${STAGE_MODE} -eq 1 ]]; then + git -C "${TARGET_ROOT}" add -- "${file_path}" + fi echo "[text-hygiene] fixed trailing whitespace: ${file_path}" >&2 else echo "[text-hygiene] trailing whitespace: ${file_path}" >&2 @@ -53,11 +59,14 @@ while IFS= read -r file_path; do # Check for text files that do not have a newline at the end of their # last line with content in it. if [[ -s "${absolute_path}" ]]; then - last_byte="$(tail -c 1 "${absolute_path}" | od -An -t u1 | tr -d '[:space:]')" + last_byte="$(tail -c 1 "${absolute_path}" | od -An -t u1 | + tr -d '[:space:]')" if [[ -n "${last_byte}" && "${last_byte}" != "10" ]]; then if [[ ${FIX_MODE} -eq 1 ]]; then printf '\n' >> "${absolute_path}" - git -C "${TARGET_ROOT}" add -- "${file_path}" + if [[ ${STAGE_MODE} -eq 1 ]]; then + git -C "${TARGET_ROOT}" add -- "${file_path}" + fi echo "[text-hygiene] fixed final newline: ${file_path}" >&2 else echo "[text-hygiene] missing final newline: ${file_path}" >&2 diff --git a/checks/linters/yamllint/run.ps1 b/checks/linters/yamllint/run.ps1 new file mode 100644 index 0000000..84ca5b7 --- /dev/null +++ b/checks/linters/yamllint/run.ps1 @@ -0,0 +1,9 @@ +# Copyright 2026 Hewlett Packard Enterprise Development LP +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $PSCommandPath +$checksRoot = Split-Path -Parent (Split-Path -Parent $scriptDir) + +. (Join-Path $checksRoot 'invoke-bash.ps1') +Invoke-BashScript -ScriptPath (Join-Path $scriptDir 'run.sh') -ScriptArgs $args diff --git a/checks/linters/yamllint/run.sh b/checks/linters/yamllint/run.sh new file mode 100755 index 0000000..aa44ea4 --- /dev/null +++ b/checks/linters/yamllint/run.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Copyright 2026 Hewlett Packard Enterprise Development LP +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/../../linter-common.sh" + +linter_parse_common_args "$@" +linter_fail_on_unknown_args +linter_require_common_args + +if ! command -v yamllint >/dev/null 2>&1; then + echo "yamllint is required but was not found in PATH" >&2 + exit 127 +fi + +files_to_check=() +while IFS= read -r file_path; do + linter_should_skip_candidate_path "${file_path}" && continue + + if linter_is_yaml_candidate "${file_path}"; then + files_to_check+=("${TARGET_ROOT}/${file_path}") + fi +done < <(linter_get_candidate_files_acmr) + +if [[ ${#files_to_check[@]} -eq 0 ]]; then + echo "[yamllint] no YAML files to lint" + exit 0 +fi + +echo "[yamllint] linting ${#files_to_check[@]} file(s)" + +# Run from TARGET_ROOT so yamllint auto-discovers .yamllint when present. +( + cd "${TARGET_ROOT}" + yamllint --strict "${files_to_check[@]}" +) diff --git a/checks/pre-commit_d/pre-commit-config.template.yaml b/checks/pre-commit_d/pre-commit-config.template.yaml new file mode 100644 index 0000000..a26074c --- /dev/null +++ b/checks/pre-commit_d/pre-commit-config.template.yaml @@ -0,0 +1,20 @@ +--- +repos: + - repo: local + hooks: + - id: forbid-code-checking-ref + name: forbid tracked code-checking-ref + entry: __HOOK_PREFIX__/checks/guard-code-checking-ref.sh --target-root . + language: script + pass_filenames: false + always_run: true + stages: [commit] + require_serial: true + - id: shellcheck + name: shellcheck + entry: __HOOK_PREFIX__/bin/run-linters.sh --mode changed --target-root . + language: script + pass_filenames: false + types: [shell] + stages: [commit] + require_serial: false diff --git a/checks/verify-executable-modes.sh b/checks/verify-executable-modes.sh index a6897db..fd0fc73 100755 --- a/checks/verify-executable-modes.sh +++ b/checks/verify-executable-modes.sh @@ -4,6 +4,7 @@ set -euo pipefail TARGET_ROOT="$(pwd)" FIX_MODE=0 +STAGE_MODE=1 usage() { cat <<'EOF' @@ -14,6 +15,7 @@ without executable mode (100755) in the git index. This covers shell scripts, Python scripts, and any other scripted file regardless of extension. Use --fix to apply git index mode fixes automatically. +Use --no-stage to apply fixes to files without staging them. EOF } @@ -31,6 +33,10 @@ while [[ $# -gt 0 ]]; do FIX_MODE=1 shift ;; + --no-stage) + STAGE_MODE=0 + shift + ;; *) echo "Unknown argument: $1" >&2 usage >&2 @@ -41,7 +47,8 @@ done TARGET_ROOT="$(cd "${TARGET_ROOT}" && pwd)" -if ! git -C "${TARGET_ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then +if ! git -C "${TARGET_ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1 +then echo "[verify-exec-mode] target is not a git working tree: ${TARGET_ROOT}" >&2 exit 2 fi @@ -66,11 +73,18 @@ while IFS= read -r path; do if [[ "${mode}" != "100755" ]]; then if [[ ${FIX_MODE} -eq 1 ]]; then - git -C "${TARGET_ROOT}" add --chmod=+x -- "${path}" - echo "[verify-exec-mode] applied +x in git index: ${path}" + if [[ ${STAGE_MODE} -eq 1 ]]; then + git -C "${TARGET_ROOT}" add --chmod=+x -- "${path}" + echo "[verify-exec-mode] applied +x in git index: ${path}" + else + echo "[verify-exec-mode] needs +x (not staged, --no-stage set):" \ + "${path}" + fi else - echo "[verify-exec-mode] missing +x in git index: ${path} (mode ${mode})" >&2 - echo "[verify-exec-mode] fix: git -C \"${TARGET_ROOT}\" add --chmod=+x -- \"${path}\"" >&2 + echo "[verify-exec-mode] missing +x in git index:" \ + "${path} (mode ${mode})" >&2 + echo "[verify-exec-mode] fix: git -C \"${TARGET_ROOT}\"" \ + "add --chmod=+x -- \"${path}\"" >&2 has_error=1 fi fi diff --git a/checks/workflow_d/basic-source-checks.template.yml b/checks/workflow_d/basic-source-checks.template.yml new file mode 100644 index 0000000..e96c579 --- /dev/null +++ b/checks/workflow_d/basic-source-checks.template.yml @@ -0,0 +1,95 @@ +--- +name: basic-source-checks + +'on': + pull_request: + +jobs: + basic-source-checks: + name: Basic Source checks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: recursive + fetch-depth: 0 + + - name: Resolve code_checking ref + run: | + REF="origin/main" + if [ -f code-checking-ref ]; then + REF="$( + grep -v '^[[:space:]]*#' code-checking-ref \ + | sed '/^[[:space:]]*$/d' \ + | head -n 1 + )" + fi + if [ -z "${REF}" ]; then + REF="origin/main" + fi + case "${REF}" in + refs/*) + FETCH_REF="${REF}" + ;; + origin/*) + FETCH_REF="refs/heads/${REF#origin/}" + ;; + pull/*/head|pull/*/merge) + FETCH_REF="refs/${REF}" + ;; + *) + FETCH_REF="refs/heads/${REF}" + ;; + esac + git -C ./__CODE_CHECKING_PATH__ fetch origin "${FETCH_REF}" + git -C ./__CODE_CHECKING_PATH__ checkout FETCH_HEAD + echo "[workflow] using code_checking ref: ${REF}" + + - name: Install linter tools + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: | + ./__CODE_CHECKING_PATH__/checks/install-linter-tools.sh \ + --library-root ./__CODE_CHECKING_PATH__ \ + --target-root . \ + --mode changed \ + --base-ref "${GITHUB_BASE_REF:-}" + + - name: Block tracked code-checking-ref + id: guard_code_checking_ref + continue-on-error: true + run: | + ./__CODE_CHECKING_PATH__/checks/guard-code-checking-ref.sh \ + --target-root . + + - name: Block active Jenkins @Library reference + id: guard_jenkins_library_pin + continue-on-error: true + run: | + ./__CODE_CHECKING_PATH__/checks/guard-jenkins-library-pin.sh \ + --target-root . + + - name: Run linters on changed files + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: ./__CODE_CHECKING_PATH__/bin/run-linters.sh + + - name: Fail if any guard check failed + if: >- + ${{ always() && + (steps.guard_code_checking_ref.outcome == 'failure' || + steps.guard_jenkins_library_pin.outcome == 'failure') }} + run: | + echo "[workflow] one or more guard checks failed" >&2 + if [ "${{ steps.guard_code_checking_ref.outcome }}" = \ + "failure" ]; then + echo "[workflow] code-checking-ref was tracked in this change" >&2 + fi + if [ "${{ steps.guard_jenkins_library_pin.outcome }}" = \ + "failure" ]; then + echo "[workflow] active Jenkins @Library reference found" >&2 + fi + echo "[workflow] keeping the final job status failed after" >&2 + echo "[workflow] running the remaining checks" >&2 + exit 1 diff --git a/cspell.config.yaml b/cspell.config.yaml index 258dcf5..ad94d7c 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -1,4 +1,5 @@ # Copyright 2026 Hewlett Packard Enterprise Development LP +--- version: '0.2' language: en useGitignore: true diff --git a/docs/git-commit-message-guidelines.md b/docs/git-commit-message-guidelines.md new file mode 100644 index 0000000..3186516 --- /dev/null +++ b/docs/git-commit-message-guidelines.md @@ -0,0 +1,110 @@ +# Git Commit Message Guidelines + +This guide captures recommended commit message conventions for this +repository. + +Consistent commit messages reduce review back-and-forth and make history +lookups faster. They also support automated release-note generation from +commit logs when teams choose to adopt it. + +## Format + +Recommended structure: + +1. Subject line +2. Blank line +3. Body (one or more wrapped paragraphs) +4. Optional grouped bullet sections +5. Optional trailers (`Refs:`, `Co-authored-by:`, and similar) + +## Subject Line + +- Start with the ticket ID when available (for example: `TKT-1234`). +- Keep it short and action-oriented. +- Target about 50 characters when practical. +- Use imperative style (`Add`, `Fix`, `Update`, `Refactor`). +- Avoid a trailing period. + +Common subject variants: + +- `: ` +- ` : ` + +Examples: + +- `TKT-1234: Add Python linting with flake8 + pylint` +- `TKT-1234 linting: Add groovylint and markdownlint` + +## Body + +- Leave the second line blank. +- Wrap lines around 72 characters. +- Explain what changed and why. +- Include useful validation context when it adds review value. + +Good pattern: + +- Paragraph 1: primary change and intent. +- Paragraph 2: related fixes discovered during validation. +- Paragraph 3: validation scope or consumer testing notes. + +## Large or Multi-Area Commits + +For broad commits, group the body by area so future readers can scan it +quickly. + +Examples: + +- `Linter framework wiring:` +- `Script implementation:` +- `Docs and tooling updates:` + +Prefer grouped scope summaries over a long unstructured file list. + +## Style Recommendations + +- Keep wording factual and concise. +- Avoid vague phrases like `misc fixes`. +- Keep the subject focused even if the body explains added scope. + +## Signed-off-by Policy + +HPE baseline policy requires a `Signed-off-by:` trailer for non-employee +contributors. + +Local group policy is stricter: require `Signed-off-by:` for all commits, +including employee-authored commits. + +## References + +For broader background, see the Git Book section on commit guidelines: + +- [Git Book: Commit Guidelines](https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project) +- [Git Project: Documentation/SubmittingPatches](https://github.com/git/git/blob/master/Documentation/SubmittingPatches) + +That section aligns well with the conventions used here, especially the +short summary line, blank-line separation, imperative mood, and wrapping +the body around 72 columns. + +## Example Template + +```text +SRE-XXXX: Short imperative summary + +One or two sentences describing the primary change and purpose. + +If needed, summarize related fixes discovered during validation and why +they were included in the same commit. + +Validation summary (optional): list tested platforms, consumer repos, or +commands. + +Linter framework wiring: +- ... + +Script implementation: +- ... + +Docs and tooling updates: +- ... +``` diff --git a/docs/integration.md b/docs/integration.md index b1eb72f..1dc9b19 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -66,7 +66,8 @@ git -C code_checking fetch origin git submodule update --remote code_checking ``` -For the staging and commit details, see [README.md](../README.md#initial-consumer-commit). +For the staging and commit details, see +[README.md](../README.md#initial-consumer-commit). Do not stage `code-checking-ref` for normal integration commits. It will usually remain visible in `git status` as an untracked file. The pre-commit @@ -135,7 +136,8 @@ Use `--no-verify` only if your consumer PR intentionally tracks The `--no-verify` bypass is acceptable here because the guard hook is protecting against accidental commits; the PR is intentional. -For routine updates to a new main tip, see [README.md](../README.md#update-to-latest). +For routine updates to a new main tip, see +[README.md](../README.md#update-to-latest). For background on submodule update behavior and remote-tracking refs, see the official Git documentation: @@ -216,7 +218,7 @@ Recommended behavior for consumer workflows: Example: ```yaml -- uses: actions/checkout@v5 +- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: recursive fetch-depth: 0 @@ -239,27 +241,31 @@ Example: git -C ./code_checking checkout FETCH_HEAD ``` -## Preventing Accidental PR Overrides +## Preventing Accidental PR and Jenkins Override Landings Recommended guardrail for consumer repositories: 1. Use the same guard script in both pre-commit and GitHub Actions: `./code_checking/checks/guard-code-checking-ref.sh`. -2. Add a guard step in the same workflow job that runs shared checks. -3. Let the guard record failure without stopping later checks, then fail the +2. Add a Jenkins landing guard in GitHub Actions: + `./code_checking/checks/guard-jenkins-library-pin.sh`. +3. Add guard steps in the same workflow job that runs shared checks. +4. Let guards record failure without stopping later checks, then fail the job at the end if the guard tripped. -4. Require only that single stable checks job in repository rules. +5. Require only that single stable checks job in repository rules. Example step: ```bash ./code_checking/checks/guard-code-checking-ref.sh --target-root . +./code_checking/checks/guard-jenkins-library-pin.sh --target-root . ``` This allows normal local override usage while still allowing intentional validation PRs that temporarily track `code-checking-ref`, without hiding the -results of later checks. The guard still leaves the final job status failed so -the PR cannot merge accidentally. +results of later checks. It also blocks active Jenkins shared-library +references such as `@Library("my-shared-lib") _` from landing. Guards still +leave the final job status failed so the PR cannot merge accidentally. Example GitHub Actions job: @@ -269,7 +275,7 @@ jobs: name: Basic Source checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: recursive fetch-depth: 0 @@ -295,16 +301,23 @@ jobs: run: | bash ./code_checking/checks/guard-code-checking-ref.sh \ --target-root . + - name: Block active Jenkins @Library reference + id: guard_jenkins_library_pin + continue-on-error: true + run: | + bash ./code_checking/checks/guard-jenkins-library-pin.sh \ + --target-root . - name: Verify executable modes run: | bash ./code_checking/checks/verify-executable-modes.sh \ --target-root . - name: Run shared linters run: bash ./code_checking/bin/run-linters.sh - - name: Fail if code-checking-ref is tracked + - name: Fail if any guard check failed if: >- ${{ always() && - steps.guard_code_checking_ref.outcome == 'failure' }} + (steps.guard_code_checking_ref.outcome == 'failure' || + steps.guard_jenkins_library_pin.outcome == 'failure') }} run: exit 1 ``` diff --git a/docs/linters.md b/docs/linters.md index dd8b77e..536967d 100644 --- a/docs/linters.md +++ b/docs/linters.md @@ -61,7 +61,19 @@ The changed-file linter set currently includes: - `groovylint` (Groovy and Jenkins DSL linting for `*.groovy`, `*.gradle`, and `Jenkinsfile*`) - `markdownlint` (Markdown linting for `*.md` and `*.markdown`) +- `yamllint` (YAML linting for `*.yml`, `*.yaml`, and `.yamllint`) - `python` (shared Python linting for `*.py` using `flake8` and `pylint`) +- `copyright` (copyright header check for script/source files) + +Fix-capable checks currently support `--fix`: + +- `verify-executable-modes` (sets executable mode in git index for shebang + files) +- `text-hygiene` (trailing whitespace and final newline fixes) +- `copyright` (missing header insertion) + +When `--fix` is enabled, fixes are staged by default. Use `--no-stage` to +apply fixes without auto-staging. Groovylint execution also includes a post-lint guard that rejects implicit script-binding assignments (bare `name = value` at statement start) to prevent @@ -125,9 +137,25 @@ Current tool preflight mapping: the Current Linters section above) - `python` linter requires both `flake8` and `pylint` on PATH and runs both tools against the same changed Python file set +- `yamllint` linter requires `yamllint` on PATH +- `copyright` linter does not require an external executable and validates + `# Copyright Hewlett Packard Enterprise Development LP` headers in + candidate files + +### Copyright Header Policy + +The `copyright` linter enforces the repository header format below for +candidate script/source files: -On Linux/macOS targets, preflight failures include install hints for common -package managers. +- `# Copyright Hewlett Packard Enterprise Development LP` + +Where `` supports compact lists/ranges such as `2024,2026-2027`. + +Current policy alignment: + +- HPE baseline policy requires this notice for covered contribution content. +- Local group policy applies the notice consistently across covered files, + with `--fix` available to insert missing headers. ### Spelling-Friendly Naming @@ -291,10 +319,10 @@ ShellCheck-specific guidance: in that case use a local `shellcheck disable=...` with a short rationale comment -## Required Checks in GitHub +## Enforcing Required Checks in GitHub -Configure branch protection in consumer repositories to require the following -status check before merging: +For public repositories configure branch protection in consumer repositories +to require the following status check before merging: - **`Basic Source checks`** — the single job that runs the guard, executable mode verification, and all linters. @@ -341,11 +369,20 @@ Use `--fix` from the command line: ./bin/run-linters.sh --fix ``` +By default `--fix` also stages corrected files via `git add`. To apply fixes +without staging (for example to review changes before staging manually), add +`--no-stage`: + +```bash +./bin/run-linters.sh --fix --no-stage +``` + Current fix coverage: - Shell script executable mode bits (for files with a shebang) - Trailing whitespace - Missing final newline +- Copyright header insertion #### Pre-commit Hooks @@ -364,16 +401,18 @@ repos: args: [--fix] ``` -When enabled, the hooks will: +To enable auto-fix without auto-staging (user preference): + +```yaml + args: [--fix, --no-stage] +``` + +When `--fix` is enabled without `--no-stage`, the hooks will: -- Report violations (as usual) - Auto-correct fixable issues and stage them via `git add` - Exit with success if all issues were corrected - Exit with failure if unfixable violations remain -This allows developers to opt into automatic cleanup on commit rather than -having it happen by default. - ### Script and Directory Responsibilities - `bin/run-linters.(sh|ps1)`: diff --git a/docs/maintenance.md b/docs/maintenance.md index 4fc06e4..b3e3c44 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -35,22 +35,31 @@ This section describes the underlying structure; exact interface paths may vary. overlapping systems: - **Branches** (older): Protection rules per-branch - **Rules** (newer): Rulesets with target patterns -3. If both exist, prefer **Rules** (rulesets) for new configurations. +3. For this repository, **Rules** (rulesets) is the managed path and source of + truth; **Branches** settings are not used. **Create or edit the rule for `main`:** -1. Create a new ruleset or branch protection rule for the `main` branch. -2. Configure the following under "Status checks": - - **Required status check**: Select `Basic Source checks` - - Rulesets: appears under "Require status checks to pass" - - Branch protection: appears under "Require status checks to pass before - merging" -3. (Optional) Enable other protections: - - **Pull Request Reviews**: "Require a pull request before merging" - - **Dismiss stale reviews**: Reviews approved before a commit is pushed are - dismissed - - **Require review from code owners**: If you maintain `CODEOWNERS` -4. Save the rule. +1. Create or edit the ruleset that targets the `main` branch. + We use the name `protect-main`. +2. Set the ruleset-level **Bypass list** to **Organization admin** with bypass + mode `For pull requests only`. +3. Under Branch targeting criteria, use `Default`. +4. Enable these branch rules: + - **Restrict deletions** + - **Require signed commits** + - **Require a pull request before merging** + - **Required approvals**: 1 + - **Dismiss stale pull request approvals when new commits are pushed** + - **Require review from Code Owners** + - **Require conversation resolution before merging** + - **Allowed merge methods**: disable merge commits; allow squash and + rebase merges + - **Require status checks to pass** + - **Status checks that are required**: `Basic Source checks` + - GitHub does not make this selectable until the workflow exists on the + default `main` branch. + - **Block force pushes** **Verification:** @@ -65,7 +74,8 @@ if any of the check steps fail (guard, executable modes, or linters). **To verify the guard works:** 1. Create a test branch from `main`. -2. Create a local `code-checking-ref` file with content: `origin/some-branch` +2. Create a local `code-checking-ref` file with content: `pull/123/head` + (or any valid git ref) 3. Commit and push: ```bash @@ -90,6 +100,110 @@ To clean up: - If you want to keep using the branch for other changes, remove `code-checking-ref`, commit that removal, and push the update. +## Repository Automation Settings + +This section documents repository-level automation settings that are configured +in GitHub Settings (not only in tracked files). + +### Dependabot Enablement Scope + +Dependabot should be enabled for this repository with a focused scope: + +- `github-actions` updates for workflow action versions in + `.github/workflows/*.yml`. + +### Recreate and Verify Dependabot Settings + +Use this runbook when bootstrapping a new repository host instance or auditing +for uncoordinated settings changes. + +#### Source of Truth + +- File-based Dependabot update behavior is defined in + `.github/dependabot.yml`. +- Repository UI security toggles and alert auto-dismiss rules are manual + settings and must be verified in GitHub Settings. + +#### Dependabot Version Updates UI Note + +The GitHub Security settings page shows a **Dependabot version updates** entry +with an "Enable" button. This is **not a toggle**. It is a status indicator +that reflects whether `.github/dependabot.yml` exists in the repository. +Clicking "Enable" starts a wizard to create that file via the GitHub web +editor. Do not use the wizard — the file is tracked in this repository and +managed via normal pull requests. If the indicator shows "Disabled", the file +is missing and should be restored from version control. + +#### Setup Steps (GitHub Web UI) + +1. Open repository Settings. +2. Open Security and quality, then Advanced Security (section labels may + change over time). +3. Enable Dependabot alerts. +4. Enable Dependabot security updates. +5. Enable grouped security updates. +6. Do not change the **Dependabot version updates** setting in the UI. It only + reports whether `.github/dependabot.yml` exists on the default `main` + branch. If it is missing, add or restore the file through a pull request. + +#### Expected Manual Alert Rule State + +Record and periodically verify these manual settings in the Dependabot alert +rules UI: + +- Dismiss low-impact alerts for development-scoped dependencies: enabled. +- Dismiss package malware alerts: disabled. Malware scanning is a paid GitHub + feature not available on the free tier. Leave disabled unless the repository + moves to a paid plan or GitHub Enterprise with that feature licensed. + +If these values are changed, update this document in the same PR with rationale +and reviewer sign-off. + +#### Verification Checklist + +At audit time, verify all of the following: + +1. `.github/dependabot.yml` exists and matches expected policy. +2. Dependabot alerts and security updates are enabled in repository settings. +3. Manual alert rule toggles match this document. +4. Dependabot is producing update PRs for `github-actions` when updates are + available. + +Initial scope is intentionally limited to workflow actions because those are +the repository-managed dependencies with the highest supply-chain relevance. +Expand scope later only when additional dependency manifests are intentionally +tracked and maintained in this repository. + +### Why This Scope + +- Keeps workflow action updates visible and reviewable via PRs. +- Reduces manual drift risk for action versions. +- Avoids noisy update churn for ecosystems not centrally managed in this repo. + +### GitHub Actions Version Pinning Policy + +- Workflow `uses:` entries are pinned to full commit SHAs. +- This is required to satisfy OpenSSF Scorecard `Pinned-Dependencies` checks. +- Include the corresponding version tag as an inline YAML comment when useful + for readability (for example, `# v6.0.2`). + +### Manual Review Cadence + +At least quarterly, maintainers should: + +1. Verify Dependabot is enabled for `github-actions` and opening update PRs. +2. Review all workflow action versions in `.github/workflows/*.yml`. +3. Check release notes for major actions used here (at minimum + `actions/checkout`). +4. Update SHA pins as needed and run full local checks before merging: + + ```bash + ./bin/run-linters.sh --mode full + ./bin/run-checks.sh + ``` + +5. Reconfirm branch/ruleset protections still require `Basic Source checks`. + ## Initial Consumer Targets - ansible-lab diff --git a/docs/usage.md b/docs/usage.md index bd0e60e..c2a981c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -69,6 +69,12 @@ Examples: Root-level `zzz-*` files are ignored by the repository `.gitignore` and are not intended to be committed. +For commit message structure and formatting guidance, see +[docs/git-commit-message-guidelines.md](docs/git-commit-message-guidelines.md). +For local policy requirements, see Signed-off usage in +[docs/git-commit-message-guidelines.md](docs/git-commit-message-guidelines.md) +and copyright header policy in [docs/linters.md](docs/linters.md). + ## IDE customization design See [docs/ide-customization.md](docs/ide-customization.md) for the diff --git a/docs/vscode-extensions.md b/docs/vscode-extensions.md index 43246ee..59a64ef 100644 --- a/docs/vscode-extensions.md +++ b/docs/vscode-extensions.md @@ -124,12 +124,14 @@ Some extensions require specific software packages to be installed on the host. #### `ms-vscode-remote.remote-ssh` - **Publisher:** Microsoft -- **Purpose:** SSH client integration for working on remote Linux hosts, lab systems, etc. +- **Purpose:** SSH client integration for working on remote Linux hosts, + lab systems, etc. - **Platform Notes:** - Windows: - Can use Windows built-in SSH (Windows 10+) if configured. - Can use Git for Windows SSH client as an alternative. - - Does NOT support Pageant or other third-party SSH agents in older versions. + - Does NOT support Pageant or other third-party SSH agents in older + versions. - macOS/Linux: Uses native SSH client. - **Important:** Direct access to lab hosts may require: - Fully qualified domain names (FQDN) for host discovery. @@ -144,7 +146,8 @@ Some extensions require specific software packages to be installed on the host. #### `ms-vscode.makefile-tools` - **Publisher:** Microsoft -- **Purpose:** Makefile support including syntax highlighting, task integration, and debugging. +- **Purpose:** Makefile support including syntax highlighting, task + integration, and debugging. - **Related Dependencies:** None (standalone). #### `ms-vscode.powershell` @@ -161,14 +164,24 @@ Some extensions require specific software packages to be installed on the host. These extensions are automatically installed as dependencies of the direct installs above. **Do not install these separately.** -| Extension | Installed As Dependency Of | Purpose | -| --------- | ------------------------- | ------- | -| `ms-python.debugpy` | `ms-python.python` | Python runtime debugging support. | -| `ms-python.vscode-pylance` | `ms-python.python` | Pylance language server for advanced type checking. | -| `ms-python.vscode-python-envs` | `ms-python.python` | Automatic Python environment detection and management. | -| `ms-python.isort` | `ms-python.python` | Optional: Python import sorting. | -| `ms-vscode-remote.remote-ssh-edit` | `ms-vscode-remote.remote-ssh` | SSH file editing and remote path handling. | -| `ms-vscode.remote-explorer` | Remote SSH/WSL | Unified explorer UI for remote connections. | +- `ms-python.debugpy` + - Installed as dependency of: `ms-python.python` + - Purpose: Python runtime debugging support. +- `ms-python.vscode-pylance` + - Installed as dependency of: `ms-python.python` + - Purpose: Pylance language server for advanced type checking. +- `ms-python.vscode-python-envs` + - Installed as dependency of: `ms-python.python` + - Purpose: Automatic Python environment detection and management. +- `ms-python.isort` + - Installed as dependency of: `ms-python.python` + - Purpose: Optional Python import sorting. +- `ms-vscode-remote.remote-ssh-edit` + - Installed as dependency of: `ms-vscode-remote.remote-ssh` + - Purpose: SSH file editing and remote path handling. +- `ms-vscode.remote-explorer` + - Installed as dependency of: Remote SSH/WSL + - Purpose: Unified explorer UI for remote connections. --- diff --git a/vscode-project-words.txt b/vscode-project-words.txt index 7f6cac7..392e5e1 100644 --- a/vscode-project-words.txt +++ b/vscode-project-words.txt @@ -32,3 +32,4 @@ venv virtualenv Vuillamy winget +yamllint