diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aaf3c7b..80fddd5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,13 +10,40 @@ on: - ".github/workflows/release.yml" jobs: + test: + name: Run tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Run tests + run: python -m unittest discover -s tests -v + release: name: Create GitHub Release + needs: test runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f2501..14648a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,23 @@ All notable changes to this project will be documented in this file. This projec Nothing yet. +## [1.25.1] - 2026-07-15 + +### Added + +- `check-munki-pkgsinfo` and `check-autopkg-recipes` now validate the type of `allow_untrusted`, `icon_hash`, `installed_size_staged`, `uninstaller_item_hash`, and `uninstaller_item_size` pkginfo keys. These are set by current Munki 7 code but aren't yet documented on the Munki wiki. + +### Changed + +- Clarified RELEASING.md on which version to use when preparing a release, since the release workflow auto-bumps `setup.py` on `dev` to the next patch version after each release. + +### Fixed + +- Fixed a bug in `generate_autopkg_processor_versions.py` that caused argument-introduction versions to be miscalculated for any AutoPkg release with Python 2-only syntax. The regex/tokenizer fallback parser used for those releases only recognized the first key of each `input_variables` dict, so most arguments were misattributed to whichever later release first became parseable by Python 3's `ast` module (typically reported as AutoPkg 1.1), causing `check-autopkg-recipes` to falsely flag long-standing recipes. +- The release workflow now runs the test suite and requires it to pass before publishing a release. +- `check-autopkg-recipes` now reports an invalid `MinimumVersion` string as a lint error instead of crashing with an uncaught exception. +- `validate_supported_architectures` no longer crashes with a `TypeError` when `supported_architectures` is present but not a list; the type mismatch is still reported by `validate_pkginfo_key_types`. + ## [1.25.0] - 2026-06-21 ### Added @@ -504,7 +521,8 @@ Nothing yet. - Initial release -[Unreleased]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.25.0...HEAD +[Unreleased]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.25.1...HEAD +[1.25.1]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.25.0...v1.25.1 [1.25.0]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.24.1...v1.25.0 [1.24.1]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.24.0...v1.24.1 [1.24.0]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.23.0...v1.24.0 diff --git a/README.md b/README.md index caf4da7..23d4ff7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ For any hook in this repo you wish to use, add the following to your pre-commit ```yaml - repo: https://github.com/homebysix/pre-commit-macadmin - rev: v1.25.0 + rev: v1.25.1 hooks: - id: check-plists # - id: ... @@ -148,7 +148,7 @@ When combining arguments that take lists (for example: `--required-keys`, `--cat ```yaml - repo: https://github.com/homebysix/pre-commit-macadmin - rev: v1.25.0 + rev: v1.25.1 hooks: - id: check-munki-pkgsinfo args: ['--catalogs', 'testing', 'stable', '--'] @@ -158,7 +158,7 @@ But if you also use the `--categories` argument, you would move the trailing `-- ```yaml - repo: https://github.com/homebysix/pre-commit-macadmin - rev: v1.25.0 + rev: v1.25.1 hooks: - id: check-munki-pkgsinfo args: ['--catalogs', 'testing', 'stable', '--categories', 'Design', 'Engineering', 'Web Browsers', '--'] @@ -170,7 +170,7 @@ If it looks better to your eye, feel free to use a multi-line list for long argu ```yaml - repo: https://github.com/homebysix/pre-commit-macadmin - rev: v1.25.0 + rev: v1.25.1 hooks: - id: check-munki-pkgsinfo args: [ diff --git a/RELEASING.md b/RELEASING.md index 7d0dabd..aa305d8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,6 +4,8 @@ Releases are largely automated via GitHub Actions. The workflow triggers when `s ## Release Process +1. Determine the version to release. After each release, the workflow auto-bumps `setup.py` on `dev` to the next **patch** version — so for a normal patch release, `setup.py` on `dev` already holds the correct next version; use that value as-is in the steps below. Only edit `setup.py` yourself first if you're bumping the **minor** or **major** version instead (see [Version Numbering](#version-numbering)). + 1. On the `dev` branch, check unit tests: .venv/bin/python -m coverage run -m unittest discover -vs tests @@ -15,7 +17,7 @@ Releases are largely automated via GitHub Actions. The workflow triggers when `s Use `--full` when the generator logic changes, historical AutoPkg tags are added or corrected, or you need to rebuild from AutoPkg `0.1.0`. The default mode appends stable releases newer than the checked-in `last_walked_version`. -1. Prepare CHANGELOG.md for release by moving `[Unreleased]` changes to a new version section: +1. Prepare CHANGELOG.md for release by moving `[Unreleased]` changes to a new version section, using the version determined in step 1 above for `X.Y.Z`: ## [Unreleased] @@ -32,7 +34,7 @@ Releases are largely automated via GitHub Actions. The workflow triggers when `s [X.Y.Z]: https://github.com/homebysix/pre-commit-macadmin/compare/vPREVIOUS...vX.Y.Z -1. Update the version in `setup.py` to match the CHANGELOG version (e.g., `2.3.6`). +1. Confirm the version in `setup.py` matches the CHANGELOG version (e.g., `2.3.6`). For a normal patch release this should already be true from the auto-bump; only change it here if you're doing a minor/major release. 1. Update the version in `README.md` examples to match (e.g., `rev: v2.3.6`). diff --git a/pre_commit_macadmin_hooks/autopkg_processor_versions.py b/pre_commit_macadmin_hooks/autopkg_processor_versions.py index 14019be..fc7bde0 100644 --- a/pre_commit_macadmin_hooks/autopkg_processor_versions.py +++ b/pre_commit_macadmin_hooks/autopkg_processor_versions.py @@ -7,10 +7,6 @@ }, "AppPkgCreator": { "_introduced_": "1.0", - "bundleid": "1.1", - "force_pkg_build": "1.1", - "pkg_path": "1.1", - "version": "1.1", "version_key": "2.4.1", }, "BrewCaskInfoProvider": { @@ -40,8 +36,6 @@ }, "Copier": { "_introduced_": "0.1.0", - "destination_path": "1.1", - "overwrite": "1.1", }, "DeprecationWarning": { "_introduced_": "1.1", @@ -61,7 +55,7 @@ }, "FileCreator": { "_introduced_": "0.1.0", - "file_mode": "1.1", + "file_mode": "0.5.0", }, "FileFinder": { "_introduced_": "0.2.3", @@ -74,7 +68,6 @@ }, "FlatPkgPacker": { "_introduced_": "0.2.4", - "destination_pkg": "1.1", }, "FlatPkgUnpacker": { "_introduced_": "0.1.0", @@ -101,20 +94,14 @@ "MunkiImporter": { "_introduced_": "0.1.0", "MUNKILIB_DIR": "2.2", - "MUNKI_PKGINFO_FILE_EXTENSION": "1.1", + "MUNKI_PKGINFO_FILE_EXTENSION": "0.3.0", "MUNKI_REPO": "0.2.5", "MUNKI_REPO_PLUGIN": "2.2", - "additional_makepkginfo_options": "1.1", "extract_icon": "2.2", "force_munki_repo_lib": "2.2", - "force_munkiimport": "1.1", "metadata_additions": "1.1", - "munkiimport_appname": "1.1", - "munkiimport_pkgname": "1.1", - "pkginfo": "1.1", - "repo_subdirectory": "1.1", - "uninstaller_pkg_path": "1.1", - "version_comparison_key": "1.1", + "uninstaller_pkg_path": "1.0.0", + "version_comparison_key": "0.3.0", }, "MunkiInfoCreator": { "_introduced_": "0.1.0", @@ -148,7 +135,6 @@ }, "PkgExtractor": { "_introduced_": "0.1.0", - "extract_root": "1.1", }, "PkgInfoCreator": { "_introduced_": "0.1.0", @@ -161,12 +147,9 @@ }, "PlistEditor": { "_introduced_": "0.1.0", - "output_plist_path": "1.1", - "plist_data": "1.1", }, "PlistReader": { "_introduced_": "0.2.5", - "plist_keys": "0.4.0", }, "SignToolVerifier": { "_introduced_": "2.3", @@ -187,14 +170,10 @@ }, "URLDownloader": { "_introduced_": "0.1.0", - "CHECK_FILESIZE_ONLY": "1.1", - "CURL_PATH": "1.1", - "PKG": "1.1", - "curl_opts": "1.1", - "download_dir": "1.1", - "filename": "1.1", + "CHECK_FILESIZE_ONLY": "0.6.0", + "CURL_PATH": "0.6.0", + "curl_opts": "1.0.4", "prefetch_filename": "1.4", - "request_headers": "1.1", }, "URLDownloaderPython": { "_introduced_": "2.4.1", @@ -217,7 +196,6 @@ }, "Versioner": { "_introduced_": "0.1.0", - "plist_version_key": "1.1", "skip_single_root_dir": "2.3", }, } diff --git a/pre_commit_macadmin_hooks/check_autopkg_recipes.py b/pre_commit_macadmin_hooks/check_autopkg_recipes.py index ef50ccf..ebaddc3 100755 --- a/pre_commit_macadmin_hooks/check_autopkg_recipes.py +++ b/pre_commit_macadmin_hooks/check_autopkg_recipes.py @@ -8,7 +8,7 @@ import sys from typing import Any -from packaging.version import Version +from packaging.version import InvalidVersion, Version from pre_commit_macadmin_hooks.autopkg_processor_versions import PROC_VERSIONS from pre_commit_macadmin_hooks.util import ( @@ -190,7 +190,14 @@ def validate_minimumversion(process, min_vers, ignore_min_vers_before, filename) # Validate that the MinimumVersion value is a string if not isinstance(min_vers, str): print(f"{filename}: MinimumVersion should be a string.") - passed = False + return False + + # Validate that the MinimumVersion value is a parseable version string. + try: + min_vers_version = Version(min_vers) + except InvalidVersion: + print(f"{filename}: MinimumVersion {min_vers!r} is not a valid version string.") + return False # Validate that the MinimumVersion value fits the processors and processor # arguments used. Unknown processors and unknown arguments fail open. @@ -213,7 +220,7 @@ def validate_minimumversion(process, min_vers, ignore_min_vers_before, filename) if Version(required_version) < Version(ignore_min_vers_before): continue - if Version(str(min_vers)) < Version(required_version): + if min_vers_version < Version(required_version): print( f"{filename}: {proc_name} processor requires minimum AutoPkg " f"version {required_version}" diff --git a/pre_commit_macadmin_hooks/util.py b/pre_commit_macadmin_hooks/util.py index de6328a..8fbbf7c 100644 --- a/pre_commit_macadmin_hooks/util.py +++ b/pre_commit_macadmin_hooks/util.py @@ -192,9 +192,15 @@ def validate_supported_architectures( """ passed = True allowed_values = ("arm64", "x86_64") - if "supported_architectures" in pkginfo: - for arch in pkginfo["supported_architectures"]: - if recipe_mode and arch.startswith("%") and arch.endswith("%"): + architectures = pkginfo.get("supported_architectures") + if isinstance(architectures, list): + for arch in architectures: + if ( + recipe_mode + and isinstance(arch, str) + and arch.startswith("%") + and arch.endswith("%") + ): # Skip values that are substituted during AutoPkg recipe runs continue if arch not in allowed_values: @@ -212,10 +218,14 @@ def validate_pkginfo_key_types(pkginfo: dict[str, Any], filename: str) -> bool: """ # Pkginfo keys and their known types. Omitted keys are left unvalidated. - # Source: https://github.com/munki/munki/wiki/Supported-Pkginfo-Keys - # Last updated 2019-03-13. + # Source: https://github.com/munki/munki/wiki/Supported-Pkginfo-Keys, plus + # keys confirmed against the Munki 7 Swift source that aren't yet + # documented on the wiki (allow_untrusted, icon_hash, installed_size_staged, + # uninstaller_item_hash, uninstaller_item_size). + # Last verified complete against the wiki 2026-06-30 and Munki source 2026-07-15. pkginfo_types = { "additional_startosinstall_options": list, + "allow_untrusted": bool, "apple_item": bool, "autoremove": bool, "blocking_applications": list, @@ -232,10 +242,12 @@ def validate_pkginfo_key_types(pkginfo: dict[str, Any], filename: str) -> bool: "force_install_after_date": datetime, "forced_install": bool, "forced_uninstall": bool, + "icon_hash": str, "icon_name": str, "installable_condition": str, "installcheck_script": str, "installed_size": int, + "installed_size_staged": int, "installer_choices_xml": list, "installer_environment": dict, "installer_item_hash": str, @@ -273,7 +285,9 @@ def validate_pkginfo_key_types(pkginfo: dict[str, Any], filename: str) -> bool: "uninstall_script": str, "uninstallable": bool, "uninstallcheck_script": str, + "uninstaller_item_hash": str, "uninstaller_item_location": str, + "uninstaller_item_size": int, "update_for": list, "version": str, "version_script": str, diff --git a/scripts/generate_autopkg_processor_versions.py b/scripts/generate_autopkg_processor_versions.py index 9933520..3ab595e 100644 --- a/scripts/generate_autopkg_processor_versions.py +++ b/scripts/generate_autopkg_processor_versions.py @@ -286,8 +286,7 @@ def string_keys_at_top_dict_level(dict_source: str) -> set[str]: level += 1 elif token.string in "}])": level -= 1 - if token.string not in ",\n": - previous_significant = token.string + previous_significant = token.string continue if token.type == tokenize.STRING and level == 1: try: diff --git a/setup.py b/setup.py index 2520557..f91f0e7 100755 --- a/setup.py +++ b/setup.py @@ -6,10 +6,11 @@ name="pre-commit-macadmin", description="Pre-commit hooks for Mac admins, client engineers, and IT consultants.", url="https://github.com/homebysix/pre-commit-macadmin", - version="1.25.0", + version="1.25.1", author="Elliot Jordan", author_email="elliot@elliotjordan.com", packages=["pre_commit_macadmin_hooks"], + python_requires=">=3.10", install_requires=["ruamel.yaml>=0.15", "packaging>=23.2"], entry_points={ "console_scripts": [ diff --git a/tests/test_check_autopkg_recipes.py b/tests/test_check_autopkg_recipes.py index fc5fc36..0efcc48 100644 --- a/tests/test_check_autopkg_recipes.py +++ b/tests/test_check_autopkg_recipes.py @@ -127,6 +127,17 @@ def test_validate_minimumversion_non_string(self): self.assertFalse(result) mock_print.assert_called_with("file.recipe: MinimumVersion should be a string.") + def test_validate_minimumversion_invalid_string(self): + process = [{"Processor": "AppPkgCreator"}] + with mock.patch("builtins.print") as mock_print: + result = target.validate_minimumversion( + process, "not-a-version", "1.0", "file.recipe" + ) + self.assertFalse(result) + mock_print.assert_called_with( + "file.recipe: MinimumVersion 'not-a-version' is not a valid version string." + ) + def test_validate_minimumversion_too_low(self): process = [{"Processor": "AppPkgCreator"}] with mock.patch("builtins.print") as mock_print: diff --git a/tests/test_generate_autopkg_processor_versions.py b/tests/test_generate_autopkg_processor_versions.py index 98e9e5f..881eaec 100644 --- a/tests/test_generate_autopkg_processor_versions.py +++ b/tests/test_generate_autopkg_processor_versions.py @@ -1,4 +1,5 @@ import argparse +import ast import subprocess import tempfile import unittest @@ -87,6 +88,38 @@ def generate_text(self, repo, output_path, **overrides): def test_normalize_tag_version_accepts_bare_tags(self): self.assertEqual(generator.normalize_tag_version("v1.1")[0], "1.1") + def test_fallback_parses_all_keys_from_python2_only_source(self): + source = "\n".join( + [ + "from autopkglib import Processor", + "", + "class ExampleProcessor(Processor):", + " input_variables = {", + ' "url": {"required": True},', + ' "download_dir": {"required": False},', + ' "filename": {"required": False},', + ' "PKG": {"required": False},', + " }", + "", + " def main(self):", + ' print "downloading"', + "", + ] + ) + + # Confirm this source needs the regex/tokenizer fallback, i.e. it + # reproduces the conditions of the regression (Python-2-only syntax + # that ast.parse cannot handle). + with self.assertRaises(SyntaxError): + ast.parse(source) + + info = generator.parse_processor_source(source) + + self.assertEqual( + info["ExampleProcessor"].args, + {"url", "download_dir", "filename", "PKG"}, + ) + def test_full_generation_detects_argument_added_after_file_move(self): with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) diff --git a/tests/test_util.py b/tests/test_util.py index 9c484be..761aa1b 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -137,6 +137,18 @@ def test_validate_supported_architectures(self): d = {} self.assertTrue(validate_supported_architectures(d, "file")) + def test_validate_supported_architectures_wrong_type_does_not_crash(self): + # A wrongly typed value is reported separately by + # validate_pkginfo_key_types; this should fail open rather than crash. + d = {"supported_architectures": "arm64"} + self.assertTrue(validate_supported_architectures(d, "file")) + d = {"supported_architectures": None} + self.assertTrue(validate_supported_architectures(d, "file")) + d = {"supported_architectures": 1} + self.assertTrue(validate_supported_architectures(d, "file")) + d = {"supported_architectures": ["arm64", 1]} + self.assertFalse(validate_supported_architectures(d, "file", recipe_mode=True)) + def test_validate_pkginfo_key_types(self): d = { "catalogs": ["foo"], @@ -147,6 +159,11 @@ def test_validate_pkginfo_key_types(self): "display_name_staged": "Example Installer", "minimum_os_version": "10.15.0", "OnDemand": True, + "allow_untrusted": True, + "icon_hash": "abc123", + "installed_size_staged": 1024, + "uninstaller_item_hash": "def456", + "uninstaller_item_size": 2048, } self.assertTrue(validate_pkginfo_key_types(d, "file")) d = {"description_staged": ["Ready to install"]} @@ -160,6 +177,10 @@ def test_validate_pkginfo_key_types(self): "OnDemand": True, } self.assertFalse(validate_pkginfo_key_types(d, "file")) + d = {"allow_untrusted": "true"} # should be bool + self.assertFalse(validate_pkginfo_key_types(d, "file")) + d = {"uninstaller_item_size": "2048"} # should be int + self.assertFalse(validate_pkginfo_key_types(d, "file")) def test_validate_shebangs(self): valid_script = "#!/bin/bash\nsomething"