diff --git a/.github/scripts/validate_changelog.py b/.github/scripts/validate_changelog.py new file mode 100755 index 0000000..bb4e7bd --- /dev/null +++ b/.github/scripts/validate_changelog.py @@ -0,0 +1,258 @@ +#!/usr/bin/python +"""Script to check if a PR has a correct changelog fragment.""" + +import argparse +import logging +import re +import subprocess +import sys + +from collections import defaultdict +from pathlib import Path + +import yaml + +FORMAT = "[%(asctime)s] - %(message)s" +logging.basicConfig(format=FORMAT) +logger = logging.getLogger("validate_changelog") +logger.setLevel(logging.DEBUG) + + +def is_changelog_file(ref: str) -> bool: + """Check if a file is a changelog fragment. + + :param ref: the file to be checked + :returns: True if file is a changelog fragment else False + """ + match = re.match(r"^changelogs/fragments/(.*)\.(yaml|yml)$", ref) + return bool(match) + + +def is_module_or_plugin(ref: str) -> bool: + """Check if a file is a module or plugin. + + :param ref: the file to be checked + :returns: True if file is a module or plugin else False + """ + prefix_list = ( + "plugins/modules", + "plugins/module_utils", + "plugins/action", + "plugins/inventory", + "plugins/lookup", + "plugins/filter", + "plugins/connection", + "plugins/become", + "plugins/cache", + "plugins/callback", + "plugins/cliconf", + "plugins/httpapi", + "plugins/netconf", + "plugins/shell", + "plugins/strategy", + "plugins/terminal", + "plugins/test", + "plugins/vars", + "roles/", + "playbooks/", + "meta/runtime.yml", + ) + return ref.startswith(prefix_list) + + +def is_documentation_file(ref: str) -> bool: + """Check if a file is a documentation file. + + :param ref: the file to be checked + :returns: True if file is a documentation file else False + """ + prefix_list = ( + "docs/", + "plugins/doc_fragments", + ) + return ref.startswith(prefix_list) + + +def is_release_pr(changes: dict[str, list[str]]) -> bool: + """Determine whether the changeset looks like a release. + + :param changes: A dictionary keyed on change status (A, M, D, etc.) of lists of changed files + :returns: True if the changes match a collection release else False + """ + # Should only have Deleted and Modified files. + if not set(changes.keys()).issubset(("D", "M")): + return False + + # All deletions should be of changelog files + if not all(is_changelog_file(x) for x in changes["D"]): + return False + + # A collection release should only change these files + if not set(changes["M"]).issubset( + ("CHANGELOG.rst", "changelogs/changelog.yaml", "galaxy.yml"), + ): + return False + + return True + + +def is_changelog_needed(changes: dict[str, list[str]]) -> bool: + """Determine whether a changelog fragment is necessary. + + :param changes: A dictionary keyed on change status (A, M, D, etc.) of lists of changed files + :returns: True if a changelog fragment is not required for this PR else False + """ + # Changes to existing plugins or modules require a changelog + # Changelog entries are not needed for new plugins or modules + # https://docs.ansible.com/ansible/latest/reference_appendices/release_and_maintenance.html#generating-changelogs + modifications = changes["M"] + changes["D"] + if any(is_module_or_plugin(x) for x in modifications): + return True + + return False + + +def is_valid_changelog_format(path: str) -> bool: + """Check if changelog fragment is formatted properly. + + :param path: the file to be checked + :returns: True if the file passes validation else False + """ + try: + config = Path("changelogs/config.yaml") + with open(config, "rb") as config_file: + changelog_config = yaml.safe_load(config_file) + changes_type = tuple(item[0] for item in changelog_config["sections"]) + changes_type += (changelog_config["trivial_section_name"],) + changes_type += (changelog_config["prelude_section_name"],) + logger.info("Found the following changelog sections: %s", changes_type) + except (OSError, yaml.YAMLError) as exc: + logger.info( + "Failed to read changelog config, using default sections instead: %s", + exc, + ) + # https://github.com/ansible-community/antsibull-changelog/blob/main/docs/changelogs.rst#changelog-fragment-categories + changes_type = ( + "release_summary", + "breaking_changes", + "major_changes", + "minor_changes", + "removed_features", + "deprecated_features", + "security_fixes", + "bugfixes", + "known_issues", + "trivial", + ) + + try: + with open(path, "rb") as file_desc: + result = list(yaml.safe_load_all(file_desc)) + + for section in result: + for key in section.keys(): + if key not in changes_type: + msg = f"{key} from {path} is not a valid changelog type" + logger.error(msg) + return False + if key == "release_summary" and not isinstance(section[key], str): + logger.error("release_summary should not be a list") + return False + elif key != "release_summary" and not isinstance(section[key], list): + logger.error( + "Changelog section %s from file %s must be a list, '%s' found instead.", + key, + path, + type(section[key]), + ) + return False + return True + except (OSError, yaml.YAMLError) as exc: + msg = f"yaml loading error for file {path} -> {exc}" + logger.error(msg) + return False + + +def run_command(cmd: str) -> tuple[int, str, str]: + """Run a command and return the response. + + :param cmd: The command to run + :returns: A tuple of (return code, stdout, stderr) + """ + with subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=True, + encoding="utf-8", + ) as proc: + out, err = proc.communicate() + return proc.returncode, out, err + + +def list_files(ref: str) -> dict[str, list[str]]: + """List all files changed since ref, grouped by change status. + + :param ref: The git ref to compare to + :returns: A dictionary keyed on change status (A, M, D, etc.) of lists of changed files + :raises ValueError: If the file gathering command fails + """ + command = "git diff origin/" + ref + " --name-status" + logger.info("Executing -> %s", command) + ret_code, stdout, stderr = run_command(command) + if ret_code != 0: + raise ValueError(stderr) + + changes: dict[str, list[str]] = defaultdict(list) + for file in stdout.split("\n"): + file_attr = file.split("\t") + if len(file_attr) == 2: + changes[file_attr[0]].append(file_attr[1]) + logger.info("changes -> %s", changes) + return changes + + +def main(ref: str) -> None: + """Run the script. + + :param ref: The pull request base ref + """ + changes = list_files(ref) + if changes: + if is_release_pr(changes): + logger.info("This PR looks like a release!") + sys.exit(0) + + changelog = [x for x in changes["A"] if is_changelog_file(x)] + logger.info("changelog files -> %s", changelog) + if not changelog: + if is_changelog_needed(changes): + logger.error( + "Missing changelog fragment. This is not required" + " only if PR adds new modules and plugins or contain" + " only documentation changes.", + ) + sys.exit(1) + logger.info( + "Changelog not required as PR adds new modules and/or" + " plugins or contain only documentation changes.", + ) + else: + invalid_changelog_files = [x for x in changelog if not is_valid_changelog_format(x)] + if invalid_changelog_files: + logger.error( + "The following changelog files are not valid -> %s", + invalid_changelog_files, + ) + sys.exit(1) + sys.exit(0) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Validate changelog file from new commit", + ) + parser.add_argument("--ref", required=True, help="Pull request base ref") + + args = parser.parse_args() + main(args.ref) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 186b0c8..ef867d4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,40 +17,278 @@ on: # yamllint disable-line rule:truthy jobs: changelog: - uses: ansible/ansible-content-actions/.github/workflows/changelog.yaml@05894f1a7922836072b5a12e80f9c2ed5f489567 - if: github.event_name == 'pull_request' + if: >- + github.event_name == 'pull_request' + && !contains(github.event.pull_request.labels.*.name, 'skip-changelog') + runs-on: ubuntu-latest + steps: + - name: Checkout collection + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: "0" + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.14" + + - name: Install dependencies + run: pip install pyyaml + + - name: Validate changelog + run: >- + python .github/scripts/validate_changelog.py + --ref "${{ github.event.pull_request.base.ref }}" + build-import: - uses: ansible/ansible-content-actions/.github/workflows/build_import.yaml@05894f1a7922836072b5a12e80f9c2ed5f489567 + runs-on: ubuntu-latest + env: + GALAXY_IMPORTER_CONFIG: /tmp/galaxy-importer.cfg + steps: + - name: Checkout collection + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Install dependencies + run: pip install ansible-core galaxy-importer + + - name: Configure galaxy-importer + run: | + cat > /tmp/galaxy-importer.cfg << EOF + [galaxy-importer] + CHECK_REQUIRED_TAGS = True + EOF + + - name: Build and import collection + run: python -m galaxy_importer.main --git-clone-path . --output-path /tmp + ansible-lint: - uses: ansible/ansible-content-actions/.github/workflows/ansible_lint.yaml@05894f1a7922836072b5a12e80f9c2ed5f489567 + runs-on: ubuntu-latest + steps: + - name: Checkout collection + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.14" + + - name: Install ansible-lint + run: pip install ansible-lint + + - name: Run ansible-lint + run: ansible-lint + + sanity-matrix: + name: "Matrix Sanity" + runs-on: ubuntu-latest + outputs: + envlist: ${{ steps.generate.outputs.envlist }} + steps: + - name: Checkout collection + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.14" + + - name: Install tox-ansible + run: pip install tox-ansible + + - name: Generate matrix + id: generate + run: >- + python -m tox --ansible --gh-matrix --matrix-scope sanity + --conf tox-ansible.ini + sanity: - uses: ansible/ansible-content-actions/.github/workflows/sanity.yaml@05894f1a7922836072b5a12e80f9c2ed5f489567 + needs: sanity-matrix + name: "${{ matrix.entry.name }}" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + entry: ${{ fromJson(needs.sanity-matrix.outputs.envlist) }} + continue-on-error: >- + ${{ contains(matrix.entry.name, 'devel') + || contains(matrix.entry.name, 'milestone') }} + steps: + - name: Checkout collection + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: "0" + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "${{ matrix.entry.python }}" + + - name: Install tox-ansible + run: pip install tox-ansible + + - name: Run sanity tests + run: >- + python -m tox --ansible -e "${{ matrix.entry.name }}" + --conf tox-ansible.ini + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + unit-galaxy-matrix: + name: "Matrix Unit" + runs-on: ubuntu-latest + outputs: + envlist: ${{ steps.generate.outputs.envlist }} + steps: + - name: Checkout collection + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.14" + + - name: Install tox-ansible + run: pip install tox-ansible + + - name: Generate matrix + id: generate + run: >- + python -m tox --ansible --gh-matrix --matrix-scope unit + --conf tox-ansible.ini + unit-galaxy: - uses: ansible/ansible-content-actions/.github/workflows/unit.yaml@05894f1a7922836072b5a12e80f9c2ed5f489567 + needs: unit-galaxy-matrix + name: "${{ matrix.entry.name }}" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + entry: ${{ fromJson(needs.unit-galaxy-matrix.outputs.envlist) }} + continue-on-error: >- + ${{ contains(matrix.entry.name, 'devel') + || contains(matrix.entry.name, 'milestone') }} + steps: + - name: Checkout collection + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: "0" + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "${{ matrix.entry.python }}" + + - name: Install tox-ansible + run: pip install tox-ansible + + - name: Install build dependencies + if: matrix.entry.python >= '3.12' + run: | + sudo apt-get update + sudo apt-get install -y build-essential libssl-dev libssh-dev + + - name: Run unit tests + run: >- + python -m tox --ansible -e "${{ matrix.entry.name }}" + --conf tox-ansible.ini + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + unit-source: - uses: ansible-network/github_actions/.github/workflows/unit_source.yml@446d4586bcde8bf6e1a9c085983bf172b59bd7b2 - with: - collection_pre_install: >- - git+https://github.com/ansible-collections/ansible.utils.git + needs: unit-galaxy-matrix + name: "source / ${{ matrix.entry.name }}" + runs-on: ubuntu-latest + env: + PY_COLORS: "1" + continue-on-error: >- + ${{ contains(matrix.entry.name, 'devel') + || contains(matrix.entry.name, 'milestone') }} + strategy: + fail-fast: false + matrix: + entry: ${{ fromJson(needs.unit-galaxy-matrix.outputs.envlist) }} + steps: + - name: Checkout collection + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: "0" + + - name: Determine ansible-core ref + id: vars + run: | + entry_name="${{ matrix.entry.name }}" + ansible_ver="${entry_name##*-py[0-9].[0-9]*-}" + if [[ "$ansible_ver" =~ ^[0-9] ]]; then + ansible_ver="stable-$ansible_ver" + fi + echo "ansible-version=$ansible_ver" >> "$GITHUB_OUTPUT" + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "${{ matrix.entry.python }}" + + - name: Install build dependencies + if: matrix.entry.python >= '3.12' + run: | + sudo apt-get update + sudo apt-get install -y build-essential libssl-dev libssh-dev + + - name: Install ansible-core (${{ steps.vars.outputs.ansible-version }}) + run: >- + pip install + https://github.com/ansible/ansible/archive/${{ steps.vars.outputs.ansible-version }}.tar.gz + --disable-pip-version-check + + - name: Install collection dependencies + run: >- + ansible-galaxy collection install --pre + git+https://github.com/ansible-collections/ansible.utils.git + -p "$HOME/.ansible/collections" + + - name: Build and install collection + run: | + ansible-galaxy collection build -vvv + ansible-galaxy collection install ./branic-system_management-*.tar.gz \ + --pre -p "$HOME/.ansible/collections" + cp galaxy.yml \ + "$HOME/.ansible/collections/ansible_collections/branic/system_management/galaxy.yml" + + - name: Install test dependencies + run: pip install pytest-ansible pytest-xdist + + - name: Print diagnostics + run: | + ansible --version + python3 -m pip list + + - name: Run unit tests + run: | + cd "$HOME/.ansible/collections/ansible_collections/branic/system_management" + python -m pytest tests/unit -vvv --showlocals \ + --ansible-host-pattern localhost + all_green: - # Run after every needed job finishes (success or failure), but skip when the - # workflow run was cancelled (e.g. superseded by concurrency) so we never "pass" on cancelled deps. if: ${{ always() && !cancelled() }} needs: - changelog - build-import + - ansible-lint - sanity - unit-galaxy - unit-source - - ansible-lint runs-on: ubuntu-latest steps: - name: Verify all required jobs succeeded env: - R_CHANGELOG: ${{ needs['changelog'].result }} + R_CHANGELOG: ${{ needs.changelog.result }} R_BUILD_IMPORT: ${{ needs['build-import'].result }} R_ANSIBLE_LINT: ${{ needs['ansible-lint'].result }} - R_SANITY: ${{ needs['sanity'].result }} + R_SANITY: ${{ needs.sanity.result }} R_UNIT_GALAXY: ${{ needs['unit-galaxy'].result }} R_UNIT_SOURCE: ${{ needs['unit-source'].result }} run: |