diff --git a/.bandit-baseline.json b/.bandit-baseline.json index c621826..2db80bb 100644 --- a/.bandit-baseline.json +++ b/.bandit-baseline.json @@ -1,30 +1,30 @@ { "errors": [], - "generated_at": "2026-08-21T17:07:47Z", + "generated_at": "2026-08-26T18:28:21Z", "metrics": { "./spoonmap.py": { - "CONFIDENCE.HIGH": 32, + "CONFIDENCE.HIGH": 33, "CONFIDENCE.LOW": 0, "CONFIDENCE.MEDIUM": 0, "CONFIDENCE.UNDEFINED": 0, "SEVERITY.HIGH": 0, "SEVERITY.LOW": 20, - "SEVERITY.MEDIUM": 12, + "SEVERITY.MEDIUM": 13, "SEVERITY.UNDEFINED": 0, - "loc": 4730, + "loc": 5056, "nosec": 0, "skipped_tests": 0 }, "_totals": { - "CONFIDENCE.HIGH": 32, + "CONFIDENCE.HIGH": 33, "CONFIDENCE.LOW": 0, "CONFIDENCE.MEDIUM": 0, "CONFIDENCE.UNDEFINED": 0, "SEVERITY.HIGH": 0, "SEVERITY.LOW": 20, - "SEVERITY.MEDIUM": 12, + "SEVERITY.MEDIUM": 13, "SEVERITY.UNDEFINED": 0, - "loc": 4730, + "loc": 5056, "nosec": 0, "skipped_tests": 0 } @@ -51,7 +51,7 @@ "test_name": "blacklist" }, { - "code": "26 from queue import Queue\n27 import xml.etree.ElementTree as etree\n28 \n", + "code": "28 import urllib.request\n29 import xml.etree.ElementTree as etree\n30 from importlib import metadata\n", "col_offset": 0, "end_col_offset": 37, "filename": "./spoonmap.py", @@ -62,16 +62,16 @@ }, "issue_severity": "LOW", "issue_text": "Using xml.etree.ElementTree to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree with the equivalent defusedxml package, or make sure defusedxml.defuse_stdlib() is called.", - "line_number": 27, + "line_number": 29, "line_range": [ - 27 + 29 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b405-import-xml-etree", "test_id": "B405", "test_name": "blacklist" }, { - "code": "81 try:\n82 subprocess.run(['stty', 'sane'], check=False, stderr=subprocess.DEVNULL)\n83 except (OSError, subprocess.SubprocessError):\n", + "code": "84 try:\n85 subprocess.run(['stty', 'sane'], check=False, stderr=subprocess.DEVNULL)\n86 except (OSError, subprocess.SubprocessError):\n", "col_offset": 8, "end_col_offset": 80, "filename": "./spoonmap.py", @@ -82,16 +82,16 @@ }, "issue_severity": "LOW", "issue_text": "Starting a process with a partial executable path", - "line_number": 82, + "line_number": 85, "line_range": [ - 82 + 85 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", "test_id": "B607", "test_name": "start_process_with_partial_path" }, { - "code": "81 try:\n82 subprocess.run(['stty', 'sane'], check=False, stderr=subprocess.DEVNULL)\n83 except (OSError, subprocess.SubprocessError):\n", + "code": "84 try:\n85 subprocess.run(['stty', 'sane'], check=False, stderr=subprocess.DEVNULL)\n86 except (OSError, subprocess.SubprocessError):\n", "col_offset": 8, "end_col_offset": 80, "filename": "./spoonmap.py", @@ -102,16 +102,16 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 82, + "line_number": 85, "line_range": [ - 82 + 85 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "709 try:\n710 root = etree.parse(xml_file)\n711 for host in root.findall('host'):\n", + "code": "921 try:\n922 root = etree.parse(xml_file)\n923 for host in root.findall('host'):\n", "col_offset": 15, "end_col_offset": 36, "filename": "./spoonmap.py", @@ -122,16 +122,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 710, + "line_number": 922, "line_range": [ - 710 + 922 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "731 try:\n732 root = etree.parse(xml_file)\n733 for host in root.findall('host'):\n", + "code": "943 try:\n944 root = etree.parse(xml_file)\n945 for host in root.findall('host'):\n", "col_offset": 15, "end_col_offset": 36, "filename": "./spoonmap.py", @@ -142,16 +142,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 732, + "line_number": 944, "line_range": [ - 732 + 944 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "813 try:\n814 proc = subprocess.Popen(masscan_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,\n815 preexec_fn=_raise_fd_limit)\n816 progress_thread = threading.Thread(target=_stream_masscan_progress, args=(proc,), daemon=True)\n", + "code": "1025 try:\n1026 proc = subprocess.Popen(masscan_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,\n1027 preexec_fn=_raise_fd_limit)\n1028 progress_thread = threading.Thread(target=_stream_masscan_progress, args=(proc,), daemon=True)\n", "col_offset": 15, "end_col_offset": 59, "filename": "./spoonmap.py", @@ -162,17 +162,17 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 814, + "line_number": 1026, "line_range": [ - 814, - 815 + 1026, + 1027 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "871 try:\n872 proc = subprocess.Popen(masscan_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,\n873 preexec_fn=_raise_fd_limit)\n874 progress_thread = threading.Thread(target=_stream_masscan_progress, args=(proc,), daemon=True)\n", + "code": "1083 try:\n1084 proc = subprocess.Popen(masscan_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,\n1085 preexec_fn=_raise_fd_limit)\n1086 progress_thread = threading.Thread(target=_stream_masscan_progress, args=(proc,), daemon=True)\n", "col_offset": 15, "end_col_offset": 59, "filename": "./spoonmap.py", @@ -183,17 +183,17 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 872, + "line_number": 1084, "line_range": [ - 872, - 873 + 1084, + 1085 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "1032 try:\n1033 proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n1034 proc.wait()\n", + "code": "1263 try:\n1264 proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n1265 proc.wait()\n", "col_offset": 15, "end_col_offset": 90, "filename": "./spoonmap.py", @@ -204,16 +204,16 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 1033, + "line_number": 1264, "line_range": [ - 1033 + 1264 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "1051 try:\n1052 root = etree.parse(output_xml)\n1053 for host in root.findall('host'):\n", + "code": "1282 try:\n1283 root = etree.parse(output_xml)\n1284 for host in root.findall('host'):\n", "col_offset": 15, "end_col_offset": 38, "filename": "./spoonmap.py", @@ -224,16 +224,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 1052, + "line_number": 1283, "line_range": [ - 1052 + 1283 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "1102 try:\n1103 masscan_process = subprocess.Popen(\n1104 masscan_cmd,\n1105 stdout=subprocess.DEVNULL,\n1106 stderr=subprocess.PIPE,\n1107 preexec_fn=_raise_fd_limit,\n1108 )\n1109 progress_thread = threading.Thread(target=run_progress_and_capture, args=(masscan_process,), daemon=True)\n", + "code": "1338 try:\n1339 masscan_process = subprocess.Popen(\n1340 masscan_cmd,\n1341 stdout=subprocess.DEVNULL,\n1342 stderr=subprocess.PIPE,\n1343 preexec_fn=_raise_fd_limit,\n1344 )\n1345 progress_thread = threading.Thread(target=run_progress_and_capture, args=(masscan_process,), daemon=True)\n", "col_offset": 26, "end_col_offset": 9, "filename": "./spoonmap.py", @@ -244,21 +244,21 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 1103, + "line_number": 1339, "line_range": [ - 1103, - 1104, - 1105, - 1106, - 1107, - 1108 + 1339, + 1340, + 1341, + 1342, + 1343, + 1344 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "1156 try:\n1157 root = etree.parse(output_file)\n1158 for host in root.findall('host'):\n", + "code": "1398 try:\n1399 root = etree.parse(output_file)\n1400 for host in root.findall('host'):\n", "col_offset": 15, "end_col_offset": 39, "filename": "./spoonmap.py", @@ -269,16 +269,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 1157, + "line_number": 1399, "line_range": [ - 1157 + 1399 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "1316 try:\n1317 proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n1318 proc.wait()\n", + "code": "1564 try:\n1565 proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n1566 proc.wait()\n", "col_offset": 15, "end_col_offset": 90, "filename": "./spoonmap.py", @@ -289,16 +289,16 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 1317, + "line_number": 1565, "line_range": [ - 1317 + 1565 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "1335 try:\n1336 root = etree.parse(output_file)\n1337 for host in root.findall('host'):\n", + "code": "1594 try:\n1595 root = etree.parse(output_file)\n1596 for host in root.findall('host'):\n", "col_offset": 15, "end_col_offset": 39, "filename": "./spoonmap.py", @@ -309,16 +309,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 1336, + "line_number": 1595, "line_range": [ - 1336 + 1595 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "1488 try:\n1489 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,\n1490 stderr=subprocess.PIPE, text=True)\n1491 _t = threading.Thread(target=_progress_reader, args=(proc.stdout,), daemon=True)\n", + "code": "1753 try:\n1754 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,\n1755 stderr=subprocess.PIPE, text=True)\n1756 _t = threading.Thread(target=_progress_reader, args=(proc.stdout,), daemon=True)\n", "col_offset": 15, "end_col_offset": 66, "filename": "./spoonmap.py", @@ -329,17 +329,17 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 1489, + "line_number": 1754, "line_range": [ - 1489, - 1490 + 1754, + 1755 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "1523 try:\n1524 root = etree.parse(output_file)\n1525 for host in root.findall('host'):\n", + "code": "1793 try:\n1794 root = etree.parse(output_file)\n1795 for host in root.findall('host'):\n", "col_offset": 15, "end_col_offset": 39, "filename": "./spoonmap.py", @@ -350,16 +350,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 1524, + "line_number": 1794, "line_range": [ - 1524 + 1794 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "2226 # non-None and work_queue.join() in nmap_scan() hangs forever.\n2227 nmap_process = subprocess.Popen(\n2228 nmap_cmd,\n2229 stdout=subprocess.DEVNULL,\n2230 stderr=subprocess.PIPE,\n2231 text=True,\n2232 start_new_session=True,\n2233 )\n2234 nmap_err_thread, nmap_err_lines = _start_stderr_reader(nmap_process.stderr)\n", + "code": "2547 # non-None and work_queue.join() in nmap_scan() hangs forever.\n2548 nmap_process = subprocess.Popen(\n2549 nmap_cmd,\n2550 stdout=subprocess.DEVNULL,\n2551 stderr=subprocess.PIPE,\n2552 text=True,\n2553 start_new_session=True,\n2554 )\n2555 nmap_err_thread, nmap_err_lines = _start_stderr_reader(nmap_process.stderr)\n", "col_offset": 31, "end_col_offset": 17, "filename": "./spoonmap.py", @@ -370,22 +370,22 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 2227, + "line_number": 2548, "line_range": [ - 2227, - 2228, - 2229, - 2230, - 2231, - 2232, - 2233 + 2548, + 2549, + 2550, + 2551, + 2552, + 2553, + 2554 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "2286 )\n2287 nse_process = subprocess.Popen(\n2288 nse_cmd,\n2289 stdout=subprocess.DEVNULL,\n2290 stderr=subprocess.PIPE,\n2291 text=True,\n2292 start_new_session=True,\n2293 )\n2294 # Same concurrent drain as the banner pass: an NSE run\n", + "code": "2615 _discard_coverage_record(nse_output)\n2616 nse_process = subprocess.Popen(\n2617 nse_cmd,\n2618 stdout=subprocess.DEVNULL,\n2619 stderr=subprocess.PIPE,\n2620 text=True,\n2621 start_new_session=True,\n2622 )\n2623 # Same concurrent drain as the banner pass: an NSE run\n", "col_offset": 38, "end_col_offset": 25, "filename": "./spoonmap.py", @@ -396,22 +396,22 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 2287, + "line_number": 2616, "line_range": [ - 2287, - 2288, - 2289, - 2290, - 2291, - 2292, - 2293 + 2616, + 2617, + 2618, + 2619, + 2620, + 2621, + 2622 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "2785 try:\n2786 root = etree.parse(fpath)\n2787 for host in root.findall('host'):\n", + "code": "3147 try:\n3148 root = etree.parse(fpath)\n3149 for host in root.findall('host'):\n", "col_offset": 19, "end_col_offset": 37, "filename": "./spoonmap.py", @@ -422,16 +422,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 2786, + "line_number": 3148, "line_range": [ - 2786 + 3148 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "2823 try:\n2824 proc = subprocess.Popen([\n2825 'nmap', '-T4', '-sS', '-sV', '--version-intensity', '0',\n2826 '-Pn', '-p', port,\n2827 *(['--source-port', source_port] if source_port else []),\n2828 ip, '-oX', out_file\n2829 ])\n2830 proc.wait()\n", + "code": "3185 try:\n3186 proc = subprocess.Popen([\n3187 'nmap', '-T4', '-sS', '-sV', '--version-intensity', '0',\n3188 '-Pn', '-p', port,\n3189 *(['--source-port', source_port] if source_port else []),\n3190 ip, '-oX', out_file\n3191 ])\n3192 proc.wait()\n", "col_offset": 27, "end_col_offset": 22, "filename": "./spoonmap.py", @@ -442,21 +442,21 @@ }, "issue_severity": "LOW", "issue_text": "Starting a process with a partial executable path", - "line_number": 2824, + "line_number": 3186, "line_range": [ - 2824, - 2825, - 2826, - 2827, - 2828, - 2829 + 3186, + 3187, + 3188, + 3189, + 3190, + 3191 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", "test_id": "B607", "test_name": "start_process_with_partial_path" }, { - "code": "2823 try:\n2824 proc = subprocess.Popen([\n2825 'nmap', '-T4', '-sS', '-sV', '--version-intensity', '0',\n2826 '-Pn', '-p', port,\n2827 *(['--source-port', source_port] if source_port else []),\n2828 ip, '-oX', out_file\n2829 ])\n2830 proc.wait()\n", + "code": "3185 try:\n3186 proc = subprocess.Popen([\n3187 'nmap', '-T4', '-sS', '-sV', '--version-intensity', '0',\n3188 '-Pn', '-p', port,\n3189 *(['--source-port', source_port] if source_port else []),\n3190 ip, '-oX', out_file\n3191 ])\n3192 proc.wait()\n", "col_offset": 27, "end_col_offset": 22, "filename": "./spoonmap.py", @@ -467,21 +467,21 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 2824, + "line_number": 3186, "line_range": [ - 2824, - 2825, - 2826, - 2827, - 2828, - 2829 + 3186, + 3187, + 3188, + 3189, + 3190, + 3191 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "2846 try:\n2847 proc = subprocess.Popen([\n2848 'nmap', '-T4', '-sS', '-Pn', '-p', port,\n2849 '--script', f'{_DIR}/nse/azure-sql-detect.nse',\n2850 '--script-timeout', '30s',\n2851 *(['--source-port', source_port] if source_port else []),\n2852 ip, '-oX', nse_out_file\n2853 ])\n2854 proc.wait()\n", + "code": "3208 try:\n3209 proc = subprocess.Popen([\n3210 'nmap', '-T4', '-sS', '-Pn', '-p', port,\n3211 '--script', f'{_NSE_DIR}/azure-sql-detect.nse',\n3212 '--script-timeout', '30s',\n3213 *(['--source-port', source_port] if source_port else []),\n3214 ip, '-oX', nse_out_file\n3215 ])\n3216 proc.wait()\n", "col_offset": 23, "end_col_offset": 18, "filename": "./spoonmap.py", @@ -492,22 +492,22 @@ }, "issue_severity": "LOW", "issue_text": "Starting a process with a partial executable path", - "line_number": 2847, + "line_number": 3209, "line_range": [ - 2847, - 2848, - 2849, - 2850, - 2851, - 2852, - 2853 + 3209, + 3210, + 3211, + 3212, + 3213, + 3214, + 3215 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", "test_id": "B607", "test_name": "start_process_with_partial_path" }, { - "code": "2846 try:\n2847 proc = subprocess.Popen([\n2848 'nmap', '-T4', '-sS', '-Pn', '-p', port,\n2849 '--script', f'{_DIR}/nse/azure-sql-detect.nse',\n2850 '--script-timeout', '30s',\n2851 *(['--source-port', source_port] if source_port else []),\n2852 ip, '-oX', nse_out_file\n2853 ])\n2854 proc.wait()\n", + "code": "3208 try:\n3209 proc = subprocess.Popen([\n3210 'nmap', '-T4', '-sS', '-Pn', '-p', port,\n3211 '--script', f'{_NSE_DIR}/azure-sql-detect.nse',\n3212 '--script-timeout', '30s',\n3213 *(['--source-port', source_port] if source_port else []),\n3214 ip, '-oX', nse_out_file\n3215 ])\n3216 proc.wait()\n", "col_offset": 23, "end_col_offset": 18, "filename": "./spoonmap.py", @@ -518,22 +518,22 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 2847, + "line_number": 3209, "line_range": [ - 2847, - 2848, - 2849, - 2850, - 2851, - 2852, - 2853 + 3209, + 3210, + 3211, + 3212, + 3213, + 3214, + 3215 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "2868 try:\n2869 tree = etree.parse(xml_file)\n2870 except Exception:\n", + "code": "3230 try:\n3231 tree = etree.parse(xml_file)\n3232 except Exception:\n", "col_offset": 19, "end_col_offset": 40, "filename": "./spoonmap.py", @@ -544,16 +544,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 2869, + "line_number": 3231, "line_range": [ - 2869 + 3231 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "2869 tree = etree.parse(xml_file)\n2870 except Exception:\n2871 continue\n2872 for host_elem in tree.findall('.//host'):\n", + "code": "3231 tree = etree.parse(xml_file)\n3232 except Exception:\n3233 continue\n3234 for host_elem in tree.findall('.//host'):\n", "col_offset": 8, "end_col_offset": 20, "filename": "./spoonmap.py", @@ -564,17 +564,17 @@ }, "issue_severity": "LOW", "issue_text": "Try, Except, Continue detected.", - "line_number": 2870, + "line_number": 3232, "line_range": [ - 2870, - 2871 + 3232, + 3233 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", "test_id": "B112", "test_name": "try_except_continue" }, { - "code": "2904 ]\n2905 result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)\n2906 if 'Valid credentials' in result.stdout:\n", + "code": "3266 ]\n3267 result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)\n3268 if 'Valid credentials' in result.stdout:\n", "col_offset": 29, "end_col_offset": 92, "filename": "./spoonmap.py", @@ -585,16 +585,16 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 2905, + "line_number": 3267, "line_range": [ - 2905 + 3267 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "3076 try:\n3077 root = etree.parse(f'{nmap_dir}/{fname}')\n3078 except etree.ParseError:\n", + "code": "3438 try:\n3439 root = etree.parse(f'{nmap_dir}/{fname}')\n3440 except etree.ParseError:\n", "col_offset": 19, "end_col_offset": 53, "filename": "./spoonmap.py", @@ -605,16 +605,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 3077, + "line_number": 3439, "line_range": [ - 3077 + 3439 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "3226 try:\n3227 root = etree.parse(fpath)\n3228 except Exception:\n", + "code": "3588 try:\n3589 root = etree.parse(fpath)\n3590 except Exception:\n", "col_offset": 19, "end_col_offset": 37, "filename": "./spoonmap.py", @@ -625,16 +625,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 3227, + "line_number": 3589, "line_range": [ - 3227 + 3589 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "3227 root = etree.parse(fpath)\n3228 except Exception:\n3229 continue\n3230 \n", + "code": "3589 root = etree.parse(fpath)\n3590 except Exception:\n3591 continue\n3592 \n", "col_offset": 8, "end_col_offset": 20, "filename": "./spoonmap.py", @@ -645,17 +645,17 @@ }, "issue_severity": "LOW", "issue_text": "Try, Except, Continue detected.", - "line_number": 3228, + "line_number": 3590, "line_range": [ - 3228, - 3229 + 3590, + 3591 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", "test_id": "B112", "test_name": "try_except_continue" }, { - "code": "4571 try:\n4572 return etree.parse(path)\n4573 except etree.ParseError as e:\n", + "code": "4933 try:\n4934 return etree.parse(path)\n4935 except etree.ParseError as e:\n", "col_offset": 15, "end_col_offset": 32, "filename": "./spoonmap.py", @@ -666,16 +666,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 4572, + "line_number": 4934, "line_range": [ - 4572 + 4934 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "4876 readline.set_completer(None) # type: ignore[name-defined]\n4877 except Exception:\n4878 pass\n4879 \n", + "code": "5238 readline.set_completer(None) # type: ignore[name-defined]\n5239 except Exception:\n5240 pass\n5241 \n", "col_offset": 8, "end_col_offset": 16, "filename": "./spoonmap.py", @@ -686,17 +686,17 @@ }, "issue_severity": "LOW", "issue_text": "Try, Except, Pass detected.", - "line_number": 4877, + "line_number": 5239, "line_range": [ - 4877, - 4878 + 5239, + 5240 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", "test_id": "B110", "test_name": "try_except_pass" }, { - "code": "4914 try:\n4915 tree = etree.parse(nmap_xml)\n4916 root_elem = tree.getroot()\n", + "code": "5276 try:\n5277 tree = etree.parse(nmap_xml)\n5278 root_elem = tree.getroot()\n", "col_offset": 19, "end_col_offset": 40, "filename": "./spoonmap.py", @@ -707,13 +707,33 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 4915, + "line_number": 5277, "line_range": [ - 4915 + 5277 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" + }, + { + "code": "5898 try:\n5899 with urllib.request.urlopen(_RELEASE_API_URL, timeout=timeout) as resp:\n5900 payload = json.loads(resp.read().decode('utf-8', 'replace'))\n", + "col_offset": 13, + "end_col_offset": 70, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 22, + "link": "https://cwe.mitre.org/data/definitions/22.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Audit url open for permitted schemes. Allowing use of file:/ or custom schemes is often unexpected.", + "line_number": 5899, + "line_range": [ + 5899 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b310-urllib-urlopen", + "test_id": "B310", + "test_name": "blacklist" } ] } \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 834eeff..be3f522 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,11 @@ name: CI on: pull_request: push: - branches: [main] + # `nightly` is here because the `tag` job below runs inside this workflow + # and is gated on `needs: [...]` plus this push trigger for main/nightly. + # Without it, pushes to nightly run no CI at all and the tag job silently + # never fires. + branches: [main, nightly] # A new push to the same PR supersedes the previous run. Scoped to pull_request # only: on `main`, cancelling the previous commit's in-progress run because a @@ -119,7 +123,7 @@ jobs: - name: Run tests run: > uv run --isolated --no-project --python ${{ matrix.python-version }} - --with pytest --with pytest-cov + --with pytest --with pytest-cov --with pyyaml --with packaging pytest tests/ -v -rs # Separate job, not a step on `test`: a lint failure and a test failure are @@ -148,7 +152,7 @@ jobs: run: uv lock --check - name: Ruff - run: uv run --frozen ruff check spoonmap.py tests/ + run: uv run --frozen ruff check spoonmap.py tests/ tools/ # SAST against a committed baseline: spoonmap shells out to masscan/nmap and # parses their XML, so a bare run reports 32 reviewed findings (subprocess @@ -363,6 +367,12 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # hatch-vcs derives the version from git describe. Under the default + # depth-1 clone this does not fail -- it silently produces a version + # computed from no tag at all (0.0.post1.dev1 where the answer is + # 0.0.1.post1.dev1), so every artifact this job inspects would carry + # a version no release ever had. + fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 @@ -373,6 +383,32 @@ jobs: - name: Build sdist and wheel run: uv build + - name: Assert artifacts carry a VCS-derived version + run: | + python3 - <<'PYEOF' + import glob + import os + import sys + + # A depth-1 clone yields 0.0.post1.dev1 -- a version derived from no + # tag. Once a tag exists, anything starting 0.0.post means the + # checkout could not see it. This is the assertion that would have + # caught a fetch-depth regression. + names = [os.path.basename(p) for p in glob.glob('dist/*')] + if not names: + sys.exit('no artifacts were built') + import subprocess + tags = subprocess.run( + ['git', 'tag'], capture_output=True, text=True, check=True + ).stdout.split() + if tags and any(n.startswith('spoonmap-0.0.post') for n in names): + sys.exit( + 'artifacts were versioned from no tag despite tags existing ' + '(shallow checkout?): ' + ', '.join(names) + ) + print('artifact versions: ' + ', '.join(names)) + PYEOF + - name: Assert sdist excludes local scratch run: | python3 - <<'PYEOF' @@ -442,7 +478,7 @@ jobs: # today, but is intentionally a separate, hand-maintained list — see # the step comment for why it must not be read from that same file. required = ( - 'spoonmap.py', 'nse/', 'tests/', 'README.md', 'CLAUDE.md', + 'spoonmap.py', 'nse/', 'tests/', 'tools/', 'README.md', 'CLAUDE.md', 'config.json.sample', 'exclusions.txt', 'pyproject.toml', 'uv.lock', '.bandit-baseline.json', '.github/workflows/', ) @@ -480,9 +516,28 @@ jobs: 'sdist nse/ does not match git-tracked nse/ exactly ' f'— missing: {nse_missing}, extra: {nse_extra}' ) + + tracked_tools = subprocess.run( + ['git', 'ls-files', 'tools/'], + capture_output=True, text=True, check=True, + ).stdout.splitlines() + tracked_tools_set = {t[len('tools/'):] for t in tracked_tools} + sdist_tools = { + s[len('tools/'):] for s in stripped + if s.startswith('tools/') and not s.endswith('/') + } + tools_missing = sorted(tracked_tools_set - sdist_tools) + tools_extra = sorted(sdist_tools - tracked_tools_set) + if tools_missing or tools_extra: + sys.exit( + 'sdist tools/ does not match git-tracked tools/ exactly ' + f'— missing: {tools_missing}, extra: {tools_extra}' + ) + print( f'sdist has all {len(required)} required entries; ' - f'nse/ matches git-tracked files exactly ({len(tracked_nse)} files)' + f'nse/ matches git-tracked files exactly ({len(tracked_nse)} files); ' + f'tools/ matches git-tracked files exactly ({len(tracked_tools_set)} files)' ) PYEOF @@ -579,3 +634,169 @@ jobs: ) print(f'installed wheel: {len(paths)} NSE paths all resolve on disk') PYEOF + + # main()'s `--version`/`--check-update` dispatch sits inside + # `# pragma: no cover`, so mutating either to `print('x')` leaves the + # whole pytest suite green -- _tool_version() and _check_for_updates() + # are unit-tested in isolation, but nothing exercises main()'s own + # argv handling. This step is that missing end-to-end guard, run + # against the installed wheel from the step above (not `./spoonmap.py` + # from the checkout): only an actual install has real distribution + # metadata, so this is also the one place that can assert the output + # is NOT _UNKNOWN_VERSION -- a checkout run would legitimately print + # that sentinel and any assertion here would be checking the wrong + # thing. + - name: Verify installed `spoonmap --version` prints a real version + run: | + wheel=$(ls dist/*.whl) + venv_dir=$(mktemp -d) + uv venv "$venv_dir/venv" + uv pip install --python "$venv_dir/venv/bin/python" "$wheel" + cd "$venv_dir" + version=$("$venv_dir/venv/bin/spoonmap" --version) + echo "spoonmap --version printed: $version" + if [ -z "$version" ]; then + echo "spoonmap --version printed nothing" >&2 + exit 1 + fi + if [ "$version" = "unknown (running from source)" ]; then + echo "spoonmap --version printed the running-from-source sentinel despite being installed from a wheel" >&2 + exit 1 + fi + # PEP 440-tolerant, not a strict X.Y.Z match: this repo has no tags + # yet, so an untagged tree's own wheel legitimately versions as + # hatch-vcs's no-guess-dev scheme (e.g. 0.0.post1.dev285), not a + # plain release tag. A strict X.Y.Z-only pattern fails on exactly + # that legitimate, common case -- and since `tag` needs `build`, + # that failure would deadlock tagging until someone hand-pushed a + # tag. This still rejects empty output and the from-source + # sentinel (both checked above), and still catches a mutation that + # prints an arbitrary non-version string. + echo "$version" | grep -Eq '^[0-9]+(\.[0-9]+)+' || { + echo "spoonmap --version did not print something starting with a parseable version: $version" >&2 + exit 1 + } + + # Cuts release tags from the commits themselves, once every other job in this + # run has passed. `nightly` cuts release candidates for whichever version the + # batch is heading toward (v0.1.0rc1, v0.1.0rc2, ...) and `main` promotes that + # same target to its final release, so they order correctly at both ends: + # + # 0.0.0 < 0.1.0rc1 < 0.1.0rc2 < 0.1.0 < 0.2.0rc1 < 0.2.0 + # + # This lives inside ci.yml, gated on `needs`, rather than in a separate + # workflow triggered by `workflow_run`. That was the original design and it + # was rejected: zizmor rates `workflow_run` an error-level dangerous trigger + # (it is the standard privilege-escalation vector, since the triggered + # workflow runs with write permissions against a ref the triggering run + # chose), and silencing that with an ignore comment is not something this + # repo does. Being a `needs` dependent of the jobs that validate the commit + # gets the same "only tag what passed CI" guarantee without the trigger, and + # without needing to check out an explicitly-passed head SHA. + # + # Nothing here parses or increments a version number. The whole policy -- + # which component moves, and to what -- lives in tools/next_version.py, where + # it is unit-tested in tests/test_next_version.py. Do not add version + # arithmetic to this job; add it to the module, where it can be tested. + tag: + name: tag release + runs-on: ubuntu-latest + timeout-minutes: 10 + # Every job that validates this commit. A tag must never appear on a commit + # that failed anything: `needs` treats a skipped or failed dependency as + # not-success, so this job simply does not run. + needs: [test, test-legacy, lint, bandit, nse-root, workflow-lint, build] + # Pushes only, and only to the two release branches. ci.yml also runs on + # pull_request, where tagging would be actively wrong. + if: >- + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/nightly') + # This workflow declares contents: read at the top level. This job alone + # needs write, to push a tag and create a release. + permissions: + contents: write + # Two pushes landing back-to-back would otherwise both compute the same tag + # and the second push would fail. Serialize per branch instead of + # cancelling: a third push superseding a still-queued second run's tag job + # is coalescing, not lost work, because the baseline this job computes + # from is the highest tag already in the repo, not a cursor advanced by + # each run — the coalesced run still sees every commit since that tag and + # cuts the version that reflects all of them. + concurrency: + group: tag-${{ github.ref }} + cancel-in-progress: false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The baseline is the highest final tag in the repository. Under the + # default depth-1 clone there are none, so this would compute from + # 0.0.0 and hand out a version that already shipped. + fetch-depth: 0 + # Deliberate exception to this repo's persist-credentials: false + # convention: this job pushes a tag and needs the token to do it. + persist-credentials: true + + - name: Configure git identity + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + - name: Compute tag + id: bump + run: | + set -euo pipefail + # main cuts the final release; nightly cuts a candidate for the same + # target. tools/next_version.py owns the decision entirely. + if [ "$GITHUB_REF" = "refs/heads/main" ]; then + channel=stable + else + channel=nightly + fi + new_tag=$(python3 tools/next_version.py --channel "$channel") + echo "Channel: $channel; tag: ${new_tag:-}" + echo "new_tag=$new_tag" >> "$GITHUB_OUTPUT" + echo "channel=$channel" >> "$GITHUB_OUTPUT" + + - name: Create tag + env: + NEW_TAG: ${{ steps.bump.outputs.new_tag }} + run: | + set -euo pipefail + # Empty means no commits since the last release -- a re-run on an + # already-tagged commit. Nothing to do, and not an error; `git tag ""` + # would fail with a message about nothing in particular. + # + # This is NOT a "no feat/fix commits, skip" early exit: a docs- or + # chore-only batch is still a release, it just cuts a patch rather + # than a minor. Only a genuinely empty batch is skipped. + if [ -z "$NEW_TAG" ]; then + echo "No commits since the last release; nothing to tag" + exit 0 + fi + # Idempotent: a re-run of this workflow must not fail the job. + if git rev-parse -q --verify "refs/tags/$NEW_TAG" >/dev/null; then + echo "Tag $NEW_TAG already exists, nothing to push" + else + git tag "$NEW_TAG" + git push origin "refs/tags/$NEW_TAG" + fi + + # Only main publishes. Nightly candidate tags exist to make builds + # addressable and to give hatch-vcs a version; they are deliberately not + # releases, so nothing ranking releases ever sees a nightly as latest. + - name: Create GitHub release + if: steps.bump.outputs.channel == 'stable' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEW_TAG: ${{ steps.bump.outputs.new_tag }} + run: | + set -euo pipefail + if [ -z "$NEW_TAG" ]; then + echo "No tag was created; no release to publish" + exit 0 + fi + if gh release view "$NEW_TAG" >/dev/null 2>&1; then + echo "Release $NEW_TAG already exists, nothing to do" + exit 0 + fi + gh release create "$NEW_TAG" --generate-notes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..023faae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +# The path for tags a human pushes by hand. The automatic policy never bumps the +# major component -- a breaking marker counts as a feature, because an automatic +# major is an irreversible published mistake waiting for one mistyped subject +# line -- so a major release is `git tag v1.0.0 && git push`, and this is what +# turns that into a release. +# +# Tags pushed by the tag job in ci.yml do NOT reach here: GitHub does not +# dispatch workflow events for refs pushed with GITHUB_TOKEN. That job creates +# its own release. +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Read-only: this job creates a release from a tag that already + # exists, so unlike the two tagging workflows it needs no credentials. + persist-credentials: false + + # `gh release create` rather than a third-party action: the runner already + # ships gh, and the tag job in ci.yml already publishes this way. Two + # release paths doing the same thing two different ways is one too many. + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists, nothing to do" + exit 0 + fi + gh release create "$TAG" --generate-notes diff --git a/CLAUDE.md b/CLAUDE.md index 5c9b2c7..3d051fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,6 +152,108 @@ README.md for the end-user walkthrough), so a change here must keep `config.json.sample` and every bundled `nse/` script landing in the wheel — the `build` CI job asserts this. +## Release Versioning + +Versions are tags, not a string in a file. `pyproject.toml` has no `version`; +hatch-vcs derives it from `git describe`, so `importlib.metadata.version('spoonmap')` +— what `--version` prints — is whatever tag the artifact was built from. + +Tags are cut by CI, from the commits themselves. `tools/next_version.py` owns the +entire policy: any `feat:` commit (or a `!` subject, or a `BREAKING CHANGE:` +footer) since the last final tag takes the batch to `X.(Y+1).0`; a batch of only +fixes, docs and chores takes it to `X.Y.(Z+1)`. **The major is never bumped +automatically** — a breaking marker counts as a feature, because an automatic +major is an irreversible published mistake waiting for one mistyped subject +line. Push a major by hand and `release.yml` will publish it. + +`nightly` cuts candidates for the version the batch is heading toward +(`v0.1.0rc1`, `v0.1.0rc2`, …) and `main` promotes that same target to its final +release. Aiming candidates one version *forward* is what makes them sort +correctly: `0.0.0 < 0.1.0rc1 < 0.1.0 < 0.2.0rc1 < 0.2.0`. This makes conventional +commit subjects load-bearing — a `feat:` typo'd as `fix:` ships as a patch. + +The tagging lives in a `tag` job **inside `ci.yml`**, gated on +`needs: [test, test-legacy, lint, bandit, nse-root, workflow-lint, build]` and +on `github.event_name == 'push'` for `main`/`nightly` only. It is deliberately +not a separate `workflow_run`-triggered workflow, which is how this was first +built: zizmor — a required job in this same file — rates `workflow_run` an +error-level dangerous trigger and exits 14, because it is the standard +privilege-escalation vector, and this repo does not silence findings with ignore +comments. Being a `needs` dependent buys the same "only tag what passed CI" +guarantee without the trigger, and without checking out an explicitly-passed +head SHA. Do not reintroduce `workflow_run` here. + +Things that fail silently rather than loudly, all guarded by +`tests/test_release_versioning.py`: + +- **`ci.yml` must run on pushes to `nightly`.** Otherwise the tag job never runs + there and no candidate is ever cut, with no error anywhere. +- **The `tag` job must keep every validating job in `needs`.** Drop one and a + tag can land on a commit that failed it. +- **`fetch-depth: 0` on both the `tag` and `build` jobs.** The baseline is the + highest final tag; a shallow clone sees none and computes from 0.0.0, handing + out a version that already shipped. Verified: a depth-1 clone does not fail — + it silently versions from no tag at all. +- **The `tag` job sets `persist-credentials: true`**, against this repo's + convention everywhere else, because it pushes a tag. It is also the only job + with `contents: write`. That exception is commented at the site and pinned by + a test; do not "fix" it. + +Version arithmetic belongs in `tools/next_version.py`, where it is unit-tested, +never in a workflow step. hate_crack carried ~70 lines of `cut -d.` duplicated +across two YAML files before extracting this module; do not reintroduce it here. + +**`main` must contain `nightly`'s commits as ancestors — merge or fast-forward +`nightly` into `main`, never squash.** `tools/next_version.py` computes +`git log ..HEAD` from whichever branch is tagging. A squash merge +collapses `nightly`'s already-released commits into one commit unreachable +from any prior tag, so that range re-lists them on `main` on every subsequent +push, forever. This fails quietly, not loudly: versions stay monotonic (the +squash commit itself is still "since the last release"), so nothing errors — +the computed target is just wrong from then on. + +## Update Checking + +`check_for_updates` in `config.json` defaults to **false**, and an absent key +means false. It is the only thing that can cause a network connection at startup. +hate_crack's equivalent defaults to true; that is deliberately inverted here, +because SpooNMAP runs from jumpboxes inside client networks where an unprompted +call to `api.github.com` is an unauthorised outbound beacon from an engagement +host. `--check-update` is the on-demand path and ignores the config. + +The gate lives in `_maybe_check_for_updates()` rather than inline in `main()` +specifically so it can be tested — `main()` is under `pragma: no cover`, and +"does a default config reach the network" is the one question here that must not +go untested. Its test patches `_check_for_updates()` itself and asserts it is +never called when the check is disabled. Patching `urllib.request.urlopen` to +raise instead does NOT work and was removed: `_check_for_updates()` catches +broadly, so it swallows the test's own tripwire and the test passes even with the +gate gone. `_check_for_updates()` swallows every failure: a courtesy check must +never delay, prompt, or abort a scan. A local version that is not an exact +release tag (running from a checkout, an rc build, a `.postN.devN` build) reports +the latest release but says the local version is not comparable to one, and +never claims an update is available. + +`_check_for_updates(quiet=True)` is the default and is what +`_maybe_check_for_updates()` uses for the launch-time path — silence on failure +is correct there. `--check-update` passes `quiet=False`: the on-demand path must +say when the check itself failed, naming the failure cheaply (HTTP status, +`URLError` reason, or exception class) when it can. Without this, an operator who +explicitly ran `--check-update` saw the same nothing-printed, exit-0 outcome for +"the network is unreachable" as for "you are up to date" — and this repo has cut +no releases yet, so `/releases/latest` 404s and the on-demand path fails on every +invocation until the first release ships. The broad `except Exception:` inside +`_check_for_updates()` stays broad either way; `quiet` only controls whether the +failure branch prints. + +`check_for_updates` round-trips through a regenerated `config.json`: +`_build_interactive_config()` takes it as a parameter and writes it explicitly +like every other field in `_CONFIG_FIELD_ORDER`, rather than relying on +`_write_interactive_config()`'s merge-with-existing-file fallback to carry it +forward — that fallback only works when a config.json happens to still be on +disk with the key already set, and silently drops it on a first-ever +regeneration. + ## Architecture ### Host Discovery (Internal) diff --git a/README.md b/README.md index b4dbb26..6409f97 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,32 @@ uv run spoonmap.py --cleanup ./spoonmap.py --cleanup /path/to/output ``` +To print the installed version: + +```bash +spoonmap --version +``` + +The version comes from the installed package's metadata, which is derived from +the repository's git tags at build time. Running `./spoonmap.py` directly from a +clone installs nothing, so that prints `unknown (running from source)` — which +is expected, not an error. + +To check whether a newer release exists: + +```bash +./spoonmap.py --check-update +``` + +**SpooNMAP never checks for updates on its own.** It makes no network connection +other than the scan itself unless you explicitly opt in, because it is routinely +run from jumpboxes inside client networks where an unprompted call out to +`api.github.com` is unwanted traffic from an engagement host. `--check-update` +performs a single check on demand. To enable the check at every startup, set +`"check_for_updates": true` in `config.json`; the key defaults to `false` and +omitting it entirely means `false`. Only stable releases are reported — +nightly release candidates are never advertised as updates. + ## Where Files Live Every operator-facing path resolves against the directory you run the command @@ -259,6 +285,7 @@ git update-index --no-skip-worktree ranges.txt | `masscan_batch_size` | Integer | Ports per masscan invocation (default: 5); prompted under "Tune advanced settings" | | `nmap_threshold` | Integer | Work-unit threshold for tool selection (default: 5,000,000 — see below); prompted under "Tune advanced settings" | | `resume` | `"True"` / `"False"` | Skip completed port discovery on restart (default: False) | +| `check_for_updates` | `"True"` / `"False"` | Contact api.github.com at startup to check for a newer release. Off unless set; see `--check-update` for a one-off check (default: False) | | `__generated_by_prompts__` | String | Present only in a config the prompts wrote. While it is present, **[d]elete**/**[a]ppend** re-ask the options using this file's values as defaults; remove it to keep them fixed | Keys beginning and ending with `__` are documentation and are ignored by the loader, so you can annotate the file freely — a re-prompted run preserves them. diff --git a/config.json.sample b/config.json.sample index 4835f84..cc941a8 100644 --- a/config.json.sample +++ b/config.json.sample @@ -16,6 +16,8 @@ "host_discovery" : true, "__resume_choices__" : "true, false (JSON booleans; legacy quoted \"True\"/\"False\" still accepted)", "resume" : false, + "__check_for_updates_note__": "Optional. When true, SpooNMAP contacts api.github.com at startup to see whether a newer release exists. Default false, and absent means false: the tool makes no network connection other than the scan itself unless you turn this on. Use --check-update for a one-off check without enabling it here.", + "check_for_updates": false, "__target_scan_choices__" : "External, Internal (case-insensitive; any other value stops the run rather than scanning with the Internal-only checks silently skipped)", "target_scan" : "Internal", "__max_rate_external_recommendation__" : "Default = 20000 (full port scan capped at 10000)", diff --git a/pyproject.toml b/pyproject.toml index 6b5c427..ccc6674 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [project] name = "spoonmap" -version = "0.1.0" +dynamic = ["version"] description = "masscan + nmap orchestration wrapper for fast network scanning" readme = "README.md" requires-python = ">=3.8" @@ -24,6 +24,14 @@ dev = [ # pinned bandit itself but let its transitive deps float. Locking it here # means `uv run --frozen bandit` gets a fully reproducible dependency set. "bandit[toml]==1.9.4", + # Test-only. tests/test_next_version.py asserts candidate/release ordering + # against a real PEP 440 parser rather than by eyeball, because two + # different pre-release schemes have been got wrong before. Not a runtime + # dependency: spoonmap.py is stdlib-only. + "packaging>=24.0", + # Test-only. tests/test_release_versioning.py parses the workflow YAML to + # assert the CI triggers and tagging steps still wire together. + "pyyaml>=6.0", ] [tool.uv] @@ -67,6 +75,19 @@ exclude_lines = [ "if __name__ == .__main__.:", ] +# The version is derived from git tags, not stored here. Tags are cut by the +# `tag` job in .github/workflows/ci.yml, using tools/next_version.py, so a +# hand-maintained version string would only ever be a second, drifting copy +# of what the tags already say. +# +# no-guess-dev an untagged commit after v0.0.1 reads 0.0.1.post1.dev1 +# rather than guessing the next release it might become. +# no-local-version drops the +g suffix, which is not a valid version +# for an index and makes tag-to-artifact comparison noisy. +[tool.hatch.version] +source = "vcs" +raw-options = { version_scheme = "no-guess-dev", local_scheme = "no-local-version" } + [tool.hatch.build.targets.wheel] only-include = ["spoonmap.py"] @@ -106,6 +127,7 @@ include = [ "spoonmap.py", "nse/", "tests/", + "tools/", "README.md", "CLAUDE.md", "config.json.sample", diff --git a/spoonmap.py b/spoonmap.py index c755078..95acc4c 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -24,7 +24,10 @@ import time import queue from queue import Queue +import urllib.error +import urllib.request import xml.etree.ElementTree as etree +from importlib import metadata _COLOR_INFO = '\x1b[38;5;51m' # electric cyan — "currently doing X" _COLOR_PROGRESS = '\x1b[38;5;118m' # neon lime green — completion status / results @@ -5392,6 +5395,13 @@ def _filter_udp_live_hosts(output_path): 'true, false (JSON booleans; legacy quoted "True"/"False" ' 'still accepted)'), ], + 'check_for_updates': [ + ('__check_for_updates_note__', + 'Optional. When true, SpooNMAP contacts api.github.com at startup to see ' + 'whether a newer release exists. Default false, and absent means false: the ' + 'tool makes no network connection other than the scan itself unless you turn ' + 'this on. Use --check-update for a one-off check without enabling it here.'), + ], 'target_scan': [ ('__target_scan_choices__', 'External, Internal (case-insensitive; any other value stops the run ' @@ -5418,7 +5428,7 @@ def _filter_udp_live_hosts(output_path): # Canonical key order for a written config.json, matching config.json.sample. _CONFIG_FIELD_ORDER = ( 'scan_categories', 'dest_ports', 'masscan_batch_size', 'banner_scan', - 'script_scan', 'host_discovery', 'resume', 'target_scan', 'max_rate', + 'script_scan', 'host_discovery', 'resume', 'check_for_updates', 'target_scan', 'max_rate', 'nmap_threads', 'nmap_threshold', 'target_file', 'output_path', 'exclusions_file', ) @@ -5427,7 +5437,8 @@ def _filter_udp_live_hosts(output_path): def _build_interactive_config(scan_categories, dest_ports, scan_type, banner_scan, script_scan, target_scan, max_rate, target_file, output_path, exclusions_file, nmap_threads, - masscan_batch_size, nmap_threshold, host_discovery): + masscan_batch_size, nmap_threshold, host_discovery, + check_for_updates=False): """Build a config.json-compatible dict from interactively collected options. The result round-trips through main()'s config loader: reloading it @@ -5444,6 +5455,15 @@ def _build_interactive_config(scan_categories, dest_ports, scan_type, banner_sca spellings still load (see _config_bool), so this is about the two files telling an operator the same thing. + ``check_for_updates`` has no interactive prompt of its own — it is only + ever turned on by hand-editing config.json — so it defaults to False here + and main() passes through whatever the prior config (or default) held. It + must still be written explicitly rather than left out of ``values``: an + omitted key relied on _write_interactive_config()'s merge-with-existing- + file behaviour to survive a regeneration, which only worked by accident + (only when a config.json happened to still be on disk with the key set) + and silently dropped the setting on a first-ever write. + Keys are emitted in ``_CONFIG_FIELD_ORDER`` with their ``_CONFIG_DOCS`` entries interleaved, so the written file documents its editable fields the way config.json.sample does. Doc entries appear only for fields actually @@ -5455,6 +5475,7 @@ def _build_interactive_config(scan_categories, dest_ports, scan_type, banner_sca 'script_scan': bool(script_scan), 'host_discovery': bool(host_discovery), 'resume': False, + 'check_for_updates': bool(check_for_updates), 'target_scan': target_scan, 'max_rate': str(max_rate), 'nmap_threads': int(nmap_threads), @@ -5775,6 +5796,10 @@ def _load_config(config_parser, dir_path, resume=False): 'host_discovery', config_parser.get('host_discovery'), True) # ORed, never overwritten: --resume can't be turned back off by the file. resume = resume or _config_bool('resume', config_parser.get('resume'), False) + # Absent means off, and absent is the normal case. This is the only way to + # enable a launch-time network call; see _check_for_updates(). + check_for_updates = _config_bool( + 'check_for_updates', config_parser.get('check_for_updates', False), False) config_generated = bool(config_parser.get(_CONFIG_GENERATED_KEY)) # Resolve relative paths in config relative to the operator directory @@ -5802,6 +5827,7 @@ def _load_config(config_parser, dir_path, resume=False): 'script_scan': script_scan, 'host_discovery': host_discovery, 'resume': resume, + 'check_for_updates': check_for_updates, 'config_generated': config_generated, } @@ -5820,6 +5846,156 @@ def _operator_dir(): return os.getcwd() +# What _tool_version() reports when there is no distribution metadata to read. +# Deliberately not a number: it flows into the update check, where anything +# parseable as a version would be compared against the latest release and +# produce a confident wrong answer. +_UNKNOWN_VERSION = 'unknown (running from source)' + + +def _tool_version(): + """The installed SpooNMAP version, or _UNKNOWN_VERSION. + + Read from distribution metadata rather than a string in this file, because + the version is derived from git tags at build time (see pyproject.toml's + [tool.hatch.version]) and a literal here would be a second, drifting copy. + + The documented invocation `./spoonmap.py` from a clone installs nothing, so + PackageNotFoundError is the *normal* case for a developer or an operator + running from a checkout -- not an error worth a warning. + """ + try: + return metadata.version('spoonmap') + except metadata.PackageNotFoundError: + return _UNKNOWN_VERSION + + +# Latest *release* specifically: GitHub's /releases/latest excludes +# pre-releases, so the vX.Y.ZrcN candidates cut on `nightly` are never +# advertised to an operator as an available update. +_RELEASE_API_URL = ( + 'https://api.github.com/repos/trustedsec/spoonmap/releases/latest' +) +_RELEASES_URL = 'https://github.com/trustedsec/spoonmap/releases' +# Short: this runs before a scan, and a hung TCP connection to a network the +# jumpbox cannot reach must not become a stalled engagement. +_UPDATE_CHECK_TIMEOUT = 5 + +_RELEASE_TAG_RE = re.compile(r'^v?(\d+)\.(\d+)\.(\d+)$') + + +def _parse_release_tag(text): + """(major, minor, patch) for a plain release, else None. + + Deliberately strict. A candidate (0.1.0rc1) or a dev build + (0.0.1.post1.dev1) is not comparable against a release without PEP 440 + semantics, and spoonmap.py is stdlib-only by design -- there is no + `packaging` here to do it properly, so anything that is not an unambiguous + X.Y.Z is declined rather than guessed at. + """ + match = _RELEASE_TAG_RE.match((text or '').strip()) + if not match: + return None + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + +def _describe_update_check_failure(exc): + """A short, cheap-to-obtain description of why the update check failed. + + Not exhaustive -- HTTPError and URLError cover the common jumpbox cases + (no releases yet -> 404, no egress -> URLError/timeout); anything else + falls back to the exception's class name rather than trying to enumerate + every failure urllib/json can produce. + """ + if isinstance(exc, urllib.error.HTTPError): + return f'api.github.com returned HTTP {exc.code}' + if isinstance(exc, urllib.error.URLError): + return f'could not reach api.github.com ({exc.reason})' + return f'could not reach api.github.com ({type(exc).__name__})' + + +def _check_for_updates(timeout=_UPDATE_CHECK_TIMEOUT, quiet=True): + """Report whether a newer release exists. Never raises; times out quickly. + + Every failure mode -- no route, DNS, TLS, rate limiting, an HTML error page + where JSON was expected, a release with no tag_name -- is swallowed. The + timeout bounds socket operations (connect, read) but not getaddrinfo, so + a blackholed resolver can still block past the timeout. An update check is + a courtesy; a scan must never fail or stall because one did. + + ``quiet`` controls only whether a *failure* is reported, and defaults to + True to preserve that launch-time contract for _maybe_check_for_updates(). + The on-demand ``--check-update`` path passes quiet=False: an operator who + explicitly asked for a check must not see silence indistinguishable from + "you are up to date", especially before this repo has cut its first + release, where the request fails every single time (api.github.com's + /releases/latest 404s with no releases published). + """ + payload = None + try: + with urllib.request.urlopen(_RELEASE_API_URL, timeout=timeout) as resp: + payload = json.loads(resp.read().decode('utf-8', 'replace')) + except Exception as exc: + # Intentionally broad: see the docstring. There is no failure here + # worth interrupting an operator for, and the set of exceptions urllib + # and json can raise between them is not worth enumerating wrongly. + if not quiet: + print(_COLOR_ERROR + + f'Update check failed: {_describe_update_check_failure(exc)}' + + _COLOR_RESET) + return + + if not isinstance(payload, dict): + if not quiet: + print(_COLOR_ERROR + + 'Update check failed: api.github.com returned an unexpected response' + + _COLOR_RESET) + return + latest_text = str(payload.get('tag_name', '')) + + latest = _parse_release_tag(latest_text) + if latest is None: + if not quiet: + print(_COLOR_ERROR + + f'Update check failed: could not parse a release version ' + f'from {latest_text!r}' + + _COLOR_RESET) + return + + current_text = _tool_version() + current = _parse_release_tag(current_text) + if current is None: + # Running from a checkout, an rc/candidate tag, or a .postN.devN + # build -- _tool_version() (and --version) can name the local + # version precisely (e.g. "0.1.0rc1") while it is still not an exact + # X.Y.Z release tag, so "unknown" would contradict --version's own + # output. Report it as not comparable and make no claim about it -- + # telling every such operator they are out of date would be wrong far + # more often than right. + print(f'Latest release: {latest_text} (local version {current_text} ' + f'is not comparable to a release). See {_RELEASES_URL}') + return + + if latest > current: + print(_COLOR_ERROR + + f'Update available: {latest_text} (current: {current_text}). ' + f'See {_RELEASES_URL}' + + _COLOR_RESET) + else: + print(f'SpooNMAP {current_text} is up to date.') + + +def _maybe_check_for_updates(enabled): + """Run the update check only if the operator turned it on. + + Separate from _check_for_updates() so the gate itself is testable: main() + is under `pragma: no cover`, and "does a default config reach the network" + is exactly the question that must not go untested. + """ + if enabled: + _check_for_updates() + + # The Main Guts def main(): # pragma: no cover -- interactive CLI entry point; orchestrates # already-independently-tested functions behind input()-driven prompts, @@ -5827,6 +6003,20 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates # giant test versus exercising each called function directly. global output_path + # Handled before the banner and before any terminal state is touched: + # `spoonmap --version` should emit one parseable line and nothing else. + if '--version' in sys.argv: + print(_tool_version()) + sys.exit(0) + # On-demand, regardless of config: asking whether an update exists should + # not require leaving the launch-time check switched on. quiet=False here + # (unlike the launch-time call below) because an operator who explicitly + # asked for a check must be told when it failed rather than seeing + # silence indistinguishable from "you are up to date". + if '--check-update' in sys.argv: + _check_for_updates(quiet=False) + sys.exit(0) + # Save initial terminal state initial_term_state = save_terminal_state() @@ -5849,6 +6039,7 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates masscan_batch_size = 5 # Default number of ports per masscan invocation nmap_threshold = 5_000_000 # Default work-unit threshold for tool selection host_discovery = None # None = prompt user; True/False = set from config + check_for_updates = False # no interactive prompt; only set via config.json # Get options from configuration file if it exists @@ -5884,6 +6075,9 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates host_discovery = cfg['host_discovery'] resume = cfg['resume'] config_generated = cfg['config_generated'] + check_for_updates = cfg['check_for_updates'] + + _maybe_check_for_updates(check_for_updates) # A config this tool wrote is a saved answer sheet, not a hand-authored # one, so ask about pre-existing output *before* the prompts: [d]elete and @@ -6190,6 +6384,7 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates scan_categories, dest_ports, scan_type, banner_scan, script_scan, target_scan, max_rate, target_file, output_path, exclusions_file, nmap_threads, masscan_batch_size, nmap_threshold, host_discovery, + check_for_updates, ) config_json_path = f'{dir_path}/config.json' if _write_interactive_config(config_json_path, interactive_config): diff --git a/tests/test_next_version.py b/tests/test_next_version.py new file mode 100644 index 0000000..4d08c8b --- /dev/null +++ b/tests/test_next_version.py @@ -0,0 +1,432 @@ +"""Tests for tools/next_version.py -- the release policy itself. + +The policy is ordinary semver with the bump derived from the batch: the second +component moves only for features, `nightly` cuts release candidates for the +version the batch is heading toward, and `main` cuts the final of that same +target. + +Aiming candidates one version forward ensures they sort above the release that +precedes them and below the release they become, and they are pre-releases. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess + +import pytest +from packaging.version import parse + +from tools.next_version import ( + commit_messages, + compute, + has_feature, + latest_final, + next_rc_number, + parse_final, + target_version, +) + +# --- the baseline ------------------------------------------------------------ + + +@pytest.mark.parametrize( + "tag,expected", + [ + ("v2.20.0", (2, 20, 0)), + ("v10.4.7", (10, 4, 7)), + # Not released versions: a candidate, a dev build, a local version, and + # the shapes a hand-pushed tag might take. + ("v2.20.1rc1", None), + ("v2.20.1.dev3", None), + ("v2.20.0+g1234567", None), + ("2.20.0", None), + ("v2.20", None), + ("nightly", None), + ], +) +def test_only_released_versions_are_baseline_candidates(tag, expected): + """The baseline must be something that actually shipped. + + Letting a candidate be the baseline would compound: 2.20.1rc1 would beget + 2.20.2rc1 without 2.20.1 ever existing. + """ + assert parse_final(tag) == expected + + +def test_latest_final_ignores_candidates_and_picks_the_highest(): + tags = ["v2.19.0", "v2.20.0", "v2.20.1rc1", "v2.20.1rc2", "v2.21.0rc1"] + assert latest_final(tags) == (2, 20, 0) + + +def test_latest_final_compares_numerically_not_lexically(): + """The bug that started all of this was a lexical tie-break: '2.19.15' sorts + above '2.20.0' as text, and below it as a version.""" + assert latest_final(["v2.9.0", "v2.10.0"]) == (2, 10, 0) + assert latest_final(["v2.19.15", "v2.20.0"]) == (2, 20, 0) + + +def test_no_tags_at_all_starts_from_zero(): + assert latest_final([]) == (0, 0, 0) + assert latest_final(["nightly", "some-marker"]) == (0, 0, 0) + + +# --- feature detection ------------------------------------------------------ + + +@pytest.mark.parametrize( + "subject", + [ + "feat: add a thing", + "feat(config): add a thing", + "feat!: replace a thing", + "feat(config)!: replace a thing", + "FEAT: shouting still counts", + ], +) +def test_feature_subjects_are_detected(subject): + assert has_feature([subject]) + + +@pytest.mark.parametrize( + "subject", + [ + "fix: repair a thing", + "fix(config): repair a thing", + "docs(changelog): write a thing", + "chore: bump a thing", + "test: cover a thing", + "refactor: move a thing", + "perf: speed a thing up", + "ci: retag a thing", + ], +) +def test_non_feature_subjects_are_not_features(subject): + assert not has_feature([subject]) + + +def test_the_word_feature_in_a_body_does_not_promote_the_batch(): + """Anchored at the subject on purpose. A fix whose body contains a line + starting with `feat:` (e.g., from git revert or squash-merge) must not cut + a minor release — only the subject matters.""" + message = "fix: revert the thing\n\nThis reverts commit abc123.\n\nfeat: add the thing\n" + assert not has_feature([message]) + + +def test_a_breaking_footer_counts_as_a_feature_not_a_major(): + """Deliberate: an automatic major is one mistyped subject away from an + irreversible published release, so major stays a human act.""" + assert has_feature(["fix: something\n\nBREAKING CHANGE: it moved\n"]) + assert has_feature(["refactor!: rename the entry point"]) + base = (2, 20, 0) + assert target_version(base, ["refactor!: rename it"]) == (2, 21, 0) + + +def test_one_feature_among_many_fixes_still_cuts_a_minor(): + messages = ["fix: a", "docs: b", "feat: c", "chore: d"] + assert target_version((2, 20, 0), messages) == (2, 21, 0) + + +# --- the target version ----------------------------------------------------- + + +def test_fix_only_batch_moves_the_third_component(): + assert target_version((2, 20, 0), ["fix: a", "docs: b"]) == (2, 20, 1) + + +def test_fix_only_batch_builds_on_a_previous_patch(): + assert target_version((2, 20, 1), ["fix: a"]) == (2, 20, 2) + + +def test_feature_batch_moves_the_second_and_zeroes_the_third(): + assert target_version((2, 20, 7), ["feat: a"]) == (2, 21, 0) + + +def test_an_empty_batch_has_no_target(): + """A workflow re-run on an already-tagged commit must cut nothing rather + than invent a version.""" + assert target_version((2, 20, 0), []) is None + assert compute("stable", ["v2.20.0"], []) is None + assert compute("nightly", ["v2.20.0"], []) is None + + +# --- candidate numbering ---------------------------------------------------- + + +def test_first_candidate_for_a_target_is_rc1(): + assert next_rc_number((2, 20, 1), ["v2.20.0"]) == 1 + + +def test_candidates_count_upward_within_a_target(): + tags = ["v2.20.0", "v2.20.1rc1", "v2.20.1rc2"] + assert next_rc_number((2, 20, 1), tags) == 3 + + +def test_candidate_numbering_is_per_target(): + """A feature landing mid-cycle changes the target, and the new target starts + its own count rather than inheriting the old one.""" + tags = ["v2.20.0", "v2.20.1rc1", "v2.20.1rc2"] + assert next_rc_number((2, 21, 0), tags) == 1 + + +def test_candidate_numbering_survives_a_deleted_tag(): + """Counts from the highest seen, not from how many exist, so deleting rc2 + cannot hand out rc2 again to a different commit.""" + tags = ["v2.20.0", "v2.20.1rc1", "v2.20.1rc3"] + assert next_rc_number((2, 20, 1), tags) == 4 + + +# --- the two channels, and the ordering that motivates the whole design ----- + + +def test_nightly_cuts_a_candidate_for_the_next_version(): + tags = ["v2.20.0"] + assert compute("nightly", tags, ["fix: a"]) == "v2.20.1rc1" + assert compute("nightly", tags, ["feat: a"]) == "v2.21.0rc1" + + +def test_main_cuts_the_final_of_the_same_target(): + """Merging nightly down promotes the candidate rather than inventing a + different number: the fix-only cycle above ends at 2.20.1, not 2.21.0.""" + tags = ["v2.20.0", "v2.20.1rc1", "v2.20.1rc2"] + assert compute("stable", tags, ["fix: a"]) == "v2.20.1" + assert compute("stable", ["v2.20.0", "v2.21.0rc1"], ["feat: a"]) == "v2.21.0" + + +def test_candidates_sort_above_the_previous_release_and_below_their_own(): + """The property both earlier schemes failed, checked with the real parser. + + A candidate must look newer than what shipped before it and older than what + it becomes. Asserted end to end across a whole fix-only cycle. + """ + shipped = parse("2.20.0") + rc1 = parse(compute("nightly", ["v2.20.0"], ["fix: a"]).lstrip("v")) + rc2 = parse( + compute("nightly", ["v2.20.0", "v2.20.1rc1"], ["fix: a", "fix: b"]).lstrip("v") + ) + final = parse( + compute("stable", ["v2.20.0", "v2.20.1rc1", "v2.20.1rc2"], ["fix: a"]).lstrip( + "v" + ) + ) + + assert shipped < rc1 < rc2 < final + assert rc1.is_prerelease and rc2.is_prerelease + assert not final.is_prerelease, "main must publish a real release, not a candidate" + + +def test_a_feature_cycle_also_orders_correctly_against_the_fix_cycle(): + """2.20.1 < 2.21.0rc1 < 2.21.0 -- a candidate for the next minor must not + look older than the patch release that preceded it.""" + patch_release = parse("2.20.1") + rc = parse(compute("nightly", ["v2.20.1"], ["feat: a"]).lstrip("v")) + final = parse(compute("stable", ["v2.20.1", "v2.21.0rc1"], ["feat: a"]).lstrip("v")) + assert patch_release < rc < final + + +def test_unknown_channel_is_a_loud_error(): + with pytest.raises(ValueError): + compute("beta", ["v2.20.0"], ["fix: a"]) + + +# --- the git boundary ------------------------------------------------------- + + +def _git(*args, cwd): + return subprocess.run( + [str(shutil.which("git")), *args], + cwd=cwd, + capture_output=True, + text=True, + check=True, + env={ + **os.environ, + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@example.invalid", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@example.invalid", + }, + ).stdout + + +def test_commit_messages_keeps_multi_line_bodies_intact(tmp_path): + """NUL-delimited for a reason: a body with a blank line would otherwise be + split into separate 'commits', and a BREAKING CHANGE footer would be read as + its own subject.""" + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", "-b", "main", cwd=repo) + (repo / "f.txt").write_text("a\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: the first thing", cwd=repo) + _git("tag", "v2.20.0", cwd=repo) + + (repo / "f.txt").write_text("b\n") + _git("add", "-A", cwd=repo) + _git( + "commit", + "-qm", + "fix: the second thing\n\nA body with a blank line.\n\nBREAKING CHANGE: yes\n", + cwd=repo, + ) + + messages = commit_messages(str(repo), (2, 20, 0)) + + assert len(messages) == 1, f"body split into separate messages: {messages}" + assert "BREAKING CHANGE: yes" in messages[0] + # And the footer is therefore seen, which is the point. + assert has_feature(messages) + + +def test_commit_messages_since_baseline_excludes_the_baseline_itself(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", "-b", "main", cwd=repo) + (repo / "f.txt").write_text("a\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "feat: shipped already", cwd=repo) + _git("tag", "v2.20.0", cwd=repo) + + assert commit_messages(str(repo), (2, 20, 0)) == [] + + (repo / "f.txt").write_text("b\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: not yet shipped", cwd=repo) + + messages = commit_messages(str(repo), (2, 20, 0)) + assert len(messages) == 1 + assert "not yet shipped" in messages[0] + assert not has_feature(messages), "the shipped feat must not leak into this batch" + + +def test_baseline_need_not_be_reachable_from_head(tmp_path): + """main's release tag can sit on a commit nightly does not contain. + + A reachability-restricted baseline would compute the next nightly from a + stale release and hand out a version below what already shipped. + """ + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", "-b", "main", cwd=repo) + (repo / "f.txt").write_text("a\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: base", cwd=repo) + _git("tag", "v2.20.0", cwd=repo) + + # A release that happened on main, on a commit this branch will not contain. + (repo / "f.txt").write_text("released\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: released on main", cwd=repo) + _git("tag", "v2.20.1", cwd=repo) + + _git("checkout", "-q", "-b", "nightly", "v2.20.0", cwd=repo) + (repo / "f.txt").write_text("nightly\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: on the nightly branch", cwd=repo) + + tags = [t for t in _git("tag", cwd=repo).splitlines() if t.strip()] + assert latest_final(tags) == (2, 20, 1), "baseline must see main's release tag" + + messages = commit_messages(str(repo), (2, 20, 1)) + got = compute("nightly", tags, messages) + assert got == "v2.20.2rc1", ( + f"the next nightly must sort above the release that already shipped, got {got}" + ) + assert parse("2.20.1") < parse(got.lstrip("v")) + + +def test_main_prints_candidate_for_nightly(tmp_path, capsys): + """main(["--channel", "nightly", "--repo-dir", str(repo)]) returns 0 and + prints a candidate tag matching ^v\\d+\\.\\d+\\.\\d+rc\\d+$.""" + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", "-b", "main", cwd=repo) + (repo / "f.txt").write_text("a\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: first thing", cwd=repo) + + from tools.next_version import main + + result = main(["--channel", "nightly", "--repo-dir", str(repo)]) + assert result == 0 + captured = capsys.readouterr() + assert captured.out.strip(), "nightly must print a tag" + assert captured.out.strip().startswith("v") + # Verify format: v0.0.1rc1 + import re + + assert re.match(r"^v\d+\.\d+\.\d+rc\d+$", captured.out.strip()) + + +def test_main_prints_final_for_stable(tmp_path, capsys): + """main(["--channel", "stable", "--repo-dir", str(repo)]) returns 0 and + prints a final tag matching ^v\\d+\\.\\d+\\.\\d+$.""" + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", "-b", "main", cwd=repo) + (repo / "f.txt").write_text("a\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: first thing", cwd=repo) + + from tools.next_version import main + + result = main(["--channel", "stable", "--repo-dir", str(repo)]) + assert result == 0 + captured = capsys.readouterr() + assert captured.out.strip(), "stable must print a tag" + assert captured.out.strip().startswith("v") + # Verify format: v0.0.1 (no rc suffix) + import re + + assert re.match(r"^v\d+\.\d+\.\d+$", captured.out.strip()) + + +def test_main_prints_nothing_when_already_tagged(tmp_path, capsys): + """main returns 0 and prints NOTHING when HEAD is already at the latest + final tag — the empty-batch case. This is what workflows treat as + 'nothing to tag'.""" + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", "-b", "main", cwd=repo) + (repo / "f.txt").write_text("a\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: first thing", cwd=repo) + _git("tag", "v0.0.1", cwd=repo) + + from tools.next_version import main + + result = main(["--channel", "stable", "--repo-dir", str(repo)]) + assert result == 0 + captured = capsys.readouterr() + assert ( + captured.out.strip() == "" + ), f"empty batch must print nothing, got: {captured.out!r}" + + +def test_git_tags_returns_tags_from_repo(tmp_path): + """git_tags() returns the tags of a real repository.""" + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", "-b", "main", cwd=repo) + (repo / "f.txt").write_text("a\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "feat: initial", cwd=repo) + _git("tag", "v0.1.0", cwd=repo) + _git("tag", "v0.1.0rc1", cwd=repo) + + from tools.next_version import git_tags + + tags = git_tags(str(repo)) + assert sorted(tags) == ["v0.1.0", "v0.1.0rc1"] + + +def test_a_repository_with_no_tags_cuts_the_first_patch(): + """SpooNMAP starts from zero: no seed tag is pushed, deliberately.""" + assert compute("stable", [], ["fix: first fix"]) == "v0.0.1" + + +def test_a_first_batch_containing_a_feature_cuts_the_first_minor(): + """The whole history is one batch on the first run, so a single `feat` + anywhere in it takes the first release to 0.1.0 rather than 0.0.1.""" + assert compute("nightly", [], ["fix: a", "feat: b", "docs: c"]) == "v0.1.0rc1" diff --git a/tests/test_release_versioning.py b/tests/test_release_versioning.py new file mode 100644 index 0000000..a95a63d --- /dev/null +++ b/tests/test_release_versioning.py @@ -0,0 +1,490 @@ +"""Guards on the release-versioning wiring. + +The policy itself -- which component moves, and to what -- lives in +tools/next_version.py and is tested in tests/test_next_version.py. Nothing here +re-implements it. + +What this file guards is everything around the policy, all of which fails +*silently*: + +* The tag job ceasing to depend on the jobs that validate the commit, which + would let a tag land on a commit that failed its tests. +* The policy module ceasing to be the only thing that produces a version, + asserted by running the actual Compute tag step and reading back the outputs + it writes to $GITHUB_OUTPUT, rather than grepping for function calls. +* A `fetch-depth` reverted to the default, which does not fail anything -- it + silently computes versions from a baseline of no tags at all. +* The behaviour of the shell that remains -- tag idempotency and the + empty-batch path -- asserted by extracting the step script from the YAML and + running it against a real git repository and a real bare remote. + +Substring assertions on YAML are sensitive to formatting and blind to +behaviour, which is backwards. Do not convert these back into them. +""" + +import os +import re +import subprocess + +import pytest +import yaml + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +WORKFLOWS = os.path.join(REPO_ROOT, '.github', 'workflows') + + +def _load(name): + with open(os.path.join(WORKFLOWS, name)) as handle: + return yaml.safe_load(handle) + + +# `on` is the YAML 1.1 boolean True, so a parsed workflow keys the trigger +# block under True rather than 'on'. This bites everyone once. +def _triggers(workflow): + return workflow.get('on', workflow.get(True)) + + +def _job(name, job_id): + return _load(name)['jobs'][job_id] + + +def _step_script(job, step_name): + for step in job['steps']: + if step.get('name') == step_name: + return step['run'] + raise AssertionError(f'no step named {step_name!r}') + + +def _checkout(job): + for step in job['steps']: + if 'actions/checkout' in str(step.get('uses', '')): + return step + raise AssertionError('no checkout step') + + +def _normalize_yaml_expr(expr): + """Normalize YAML expression for comparison: collapse whitespace.""" + return re.sub(r'\s+', ' ', expr.strip()) + + +# --- triggers and gating ----------------------------------------------------- + + +def test_ci_runs_on_pushes_to_both_release_branches(): + """A push to `nightly` that runs no CI would never reach the tag job, and + no candidate would ever be cut. Nothing errors; tags just stop appearing.""" + branches = _triggers(_load('ci.yml'))['push']['branches'] + assert 'nightly' in branches + assert 'main' in branches + + +def test_the_tag_job_waits_for_every_validating_job(): + """A tag must never appear on a commit that failed anything. `needs` treats + a failed or skipped dependency as not-success, so the job simply does not + run -- but only for jobs actually listed here. + + Exclude any job whose own `needs` contains 'tag' (e.g., a future publish job). + """ + ci = _load('ci.yml') + needs = set(ci['jobs']['tag']['needs']) + + # Helper to normalize job's needs list, handling both list and string forms + def get_needs_set(job_def): + job_needs = job_def.get('needs') + if isinstance(job_needs, str): + # Normalize string form to list to avoid substring matching + return {job_needs} + elif isinstance(job_needs, list): + return set(job_needs) + else: + return set() + + # Exclude jobs that depend on tag (self-referencing jobs like publish) + validating = { + j for j in ci['jobs'] + if j != 'tag' and 'tag' not in get_needs_set(ci['jobs'][j]) + } + missing = validating - needs + assert not missing, f'tag job does not depend on: {sorted(missing)}' + + +def test_the_tag_job_never_runs_on_pull_requests(): + """ci.yml also runs on pull_request, where tagging would be actively + wrong. Assert the entire normalized if expression, not fragments.""" + condition = _job('ci.yml', 'tag')['if'] + normalized = _normalize_yaml_expr(condition) + # Must assert the full expression to catch && → || mutations + expected = _normalize_yaml_expr( + "github.event_name == 'push' && " + "(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/nightly')" + ) + assert normalized == expected, f'Expected: {expected}\nGot: {normalized}' + + +def test_only_the_tag_job_can_write(): + """The workflow is read-only; exactly one job escalates, and only to what + pushing a tag and cutting a release requires.""" + # Jobs permitted to declare their own `permissions:`. Adding a name here is + # a deliberate decision to let another job escalate, and it should come with + # a reason -- `tag` needs contents: write to push a tag and cut a release. + # A derived rule was tried here and removed: exempting anything that depends + # on `tag` let a future job grant itself contents: write with nothing + # tripping, which is the invariant this test exists to hold. + MAY_DECLARE_PERMISSIONS = {'tag'} + + ci = _load('ci.yml') + assert ci['permissions'] == {'contents': 'read'} + assert ci['jobs']['tag']['permissions'] == {'contents': 'write'} + for job_id, job in ci['jobs'].items(): + if job_id not in MAY_DECLARE_PERMISSIONS: + assert 'permissions' not in job, job_id + + +def test_the_tag_job_does_not_cancel_itself(): + """Two pushes landing together would otherwise both compute the same tag. + Serialize per branch instead of cancelling, so no push is skipped.""" + concurrency = _job('ci.yml', 'tag')['concurrency'] + assert concurrency['cancel-in-progress'] is False + + +def test_tag_job_concurrency_group_is_branch_specific(): + """The tag job's concurrency group must not collide with the workflow-level + group, which would deadlock the job against itself. It must also interpolate + github.ref to serialize by branch.""" + tag_job = _job('ci.yml', 'tag') + tag_concurrency_group = tag_job['concurrency']['group'] + workflow_concurrency_group = _load('ci.yml')['concurrency']['group'] + + # Groups must be different to avoid deadlock + assert tag_concurrency_group != workflow_concurrency_group + # Tag group must reference github.ref to serialize by branch + assert 'github.ref' in tag_concurrency_group + + +def test_all_jobs_declare_timeout_minutes(): + """Every job must declare timeout-minutes to bound execution. A hung job + holding contents: write is the worst one to lose that bound.""" + ci = _load('ci.yml') + for job_id, job in ci['jobs'].items(): + assert 'timeout-minutes' in job, f'job {job_id} missing timeout-minutes' + + +# --- checkout depth ---------------------------------------------------------- + + +@pytest.mark.parametrize('job_id', ['tag', 'build']) +def test_version_deriving_jobs_fetch_all_history(job_id): + """Both jobs derive a version from git describe. A shallow clone does not + fail either of them -- it silently computes from a baseline of no tags, + which is how a wrong version ships without anything going red. GitHub + coerces fetch-depth to a string, so compare as string.""" + depth = _checkout(_job('ci.yml', job_id))['with']['fetch-depth'] + assert str(depth).lower() == '0', f'job {job_id} must have fetch-depth: 0, got {depth}' + + +def test_the_tag_job_keeps_its_credentials(): + """Deliberate exception to this repo's persist-credentials: false rule: + this job pushes a tag and needs the token. GitHub coerces to string.""" + persist = _checkout(_job('ci.yml', 'tag'))['with']['persist-credentials'] + assert str(persist).lower() == 'true' + + +def test_every_other_checkout_drops_its_credentials(): + """All other jobs, in EVERY workflow file, must drop credentials -- not + just ci.yml's. release.yml also declares `contents: write` (for `gh + release create`) and is the other place a checkout could quietly gain a + push-capable token. `tag` (in ci.yml) is the single documented exception: + it is the only job anywhere that pushes a tag.""" + seen_files = set() + for filename in os.listdir(WORKFLOWS): + if not filename.endswith(('.yml', '.yaml')): + continue + seen_files.add(filename) + workflow = _load(filename) + for job_id, job in workflow['jobs'].items(): + if filename == 'ci.yml' and job_id == 'tag': + continue + persist = _checkout(job)['with']['persist-credentials'] + assert str(persist).lower() == 'false', f'{filename}:{job_id}' + + # A floor: the loop above passes vacuously if os.listdir() ever returned + # only one workflow file (e.g. a filesystem glitch, or a future rename + # that no longer matches .yml/.yaml). Assert both files this test exists + # to cover were actually visited. + assert {'ci.yml', 'release.yml'} <= seen_files, seen_files + + +# --- the policy module is the only thing that produces a version ------------- + + +def test_exactly_one_call_to_the_policy_module(): + """The policy module must be called exactly once to produce the version. + Count against the parsed run bodies of the tag job's steps, not the whole + file (which may contain --repo-dir in comments).""" + job = _job('ci.yml', 'tag') + calls = sum( + len(re.findall(r'tools/next_version\.py\s+--channel', step.get('run', ''))) + for step in job['steps'] + ) + assert calls == 1, f'expected exactly one call to tools/next_version.py, found {calls}' + + +def test_both_channels_are_reachable(): + """main cuts the final release, nightly cuts a candidate for the same + target. A job that only ever computed one channel would silently tag + nightly builds as releases, or never cut a release at all.""" + script = _step_script(_job('ci.yml', 'tag'), 'Compute tag') + assert 'channel=stable' in script + assert 'channel=nightly' in script + + +@pytest.mark.parametrize('branch,expected_channel', [ + ('refs/heads/main', 'stable'), + ('refs/heads/nightly', 'nightly'), +]) +def test_compute_tag_step_assigns_correct_channel(branch, expected_channel, tmp_path): + """The Compute tag step must actually WRITE the channel and new_tag outputs + to $GITHUB_OUTPUT. Runs the step in isolation with a real bash subprocess.""" + script = _step_script(_job('ci.yml', 'tag'), 'Compute tag') + + # Minimal environment: only PATH, HOME, and the variables the step needs + env = { + 'PATH': os.environ.get('PATH', '/usr/bin:/bin'), + 'HOME': os.environ.get('HOME', '/tmp'), + 'GITHUB_REF': branch, + 'GITHUB_OUTPUT': str(tmp_path / 'github_output'), + } + + result = subprocess.run( + ['bash', '-c', script], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f'Compute tag step failed: {result.stderr}' + + # Parse the $GITHUB_OUTPUT file to read back the values the step wrote + output_file = tmp_path / 'github_output' + assert output_file.exists(), 'Compute tag step did not write $GITHUB_OUTPUT' + + output_contents = output_file.read_text() + parsed = {} + for line in output_contents.splitlines(): + if '=' in line: + key, value = line.split('=', 1) + parsed[key] = value + + # Assert the channel is assigned correctly + assert 'channel' in parsed, f'channel key not in GITHUB_OUTPUT: {parsed}' + assert parsed['channel'] == expected_channel, \ + f'Expected channel={expected_channel}, got channel={parsed["channel"]}' + + # Assert both keys are present unconditionally. This catches key renames + # (e.g., echo "new_tag=$new_tag" renamed to echo "tagname=$new_tag"). + assert 'new_tag' in parsed, f'new_tag key not in GITHUB_OUTPUT: {parsed}' + assert 'channel' in parsed, f'channel key not in GITHUB_OUTPUT (checked twice): {parsed}' + + # Validate new_tag value shape without guarding. Empty is legitimate when HEAD + # sits exactly on a tag (no new commits). Non-empty must match the channel's + # tag format: stable uses v0.1.0, nightly uses v0.1.0rc1. + new_tag = parsed['new_tag'] + if expected_channel == 'stable': + tag_pattern = r'^v\d+\.\d+\.\d+$' + else: # nightly + tag_pattern = r'^v\d+\.\d+\.\d+rc\d+$' + + # Assert value is either empty or matches the channel-appropriate format. + # No if guards — validation always runs unconditionally. + is_empty = new_tag == '' + matches_pattern = re.match(tag_pattern, new_tag) is not None + assert is_empty or matches_pattern, \ + f'new_tag must be empty or match {tag_pattern}, got: {new_tag!r}' + + # Count key occurrences in raw file. Mutations that write then blank create a + # duplicate key line (echo "new_tag=" appended after the real write). This + # catches the mutation even when the value is legitimately empty. + new_tag_count = output_contents.count('new_tag=') + channel_count = output_contents.count('channel=') + assert new_tag_count == 1, \ + f'new_tag key appears {new_tag_count} times (expected 1): {output_contents!r}' + assert channel_count == 1, \ + f'channel key appears {channel_count} times (expected 1): {output_contents!r}' + + +def test_the_pushed_tag_is_the_one_the_policy_computed(): + """The Create tag step must use the tag computed by Compute tag step.""" + job = _job('ci.yml', 'tag') + compute = [s for s in job['steps'] if 'next_version.py' in s.get('run', '')] + assert len(compute) == 1 + step_id = compute[0]['id'] + create = [s for s in job['steps'] if s.get('name') == 'Create tag'][0] + assert create['env']['NEW_TAG'] == '${{ steps.%s.outputs.new_tag }}' % step_id + + +def test_no_shell_version_arithmetic(): + """Second line of defence. Version math in YAML cannot be unit-tested, + which is the entire reason tools/next_version.py exists. Scope to tag job + steps only, not the whole file.""" + job = _job('ci.yml', 'tag') + script = '\n'.join(step.get('run', '') for step in job['steps']) + for banned in ('cut -d.', '$((', 'awk -F.'): + assert banned not in script, f'version arithmetic in tag job: {banned}' + + +def test_only_stable_publishes_a_release(): + """Nightly candidates exist to make builds addressable, not to be releases. + Publishing them would make anything ranking releases see a candidate as + latest. Assert the full if condition, not fragments.""" + release_steps = [ + s for s in _job('ci.yml', 'tag')['steps'] + if s.get('name') == 'Create GitHub release' + ] + assert len(release_steps) == 1 + release_step = release_steps[0] + + # Assert the full if condition to catch || true appending + if_condition = release_step.get('if', '') + normalized = _normalize_yaml_expr(if_condition) + assert normalized == _normalize_yaml_expr("steps.bump.outputs.channel == 'stable'"), \ + f'release step if condition must be exactly "steps.bump.outputs.channel == \'stable\'", got: {normalized}' + + +# --- the behaviour of the shell that remains --------------------------------- + + +def _git(repo, *args): + return subprocess.run( + ['git', *args], cwd=repo, capture_output=True, text=True, check=True + ).stdout + + +@pytest.fixture +def repo_with_remote(tmp_path): + """A real repository with a real bare origin, so `git push` is exercised.""" + remote = tmp_path / 'remote.git' + subprocess.run(['git', 'init', '-q', '--bare', str(remote)], check=True) + repo = tmp_path / 'repo' + subprocess.run(['git', 'init', '-q', str(repo)], check=True) + _git(repo, 'config', 'user.email', 'test@example.com') + _git(repo, 'config', 'user.name', 'test') + (repo / 'f.txt').write_text('x') + _git(repo, 'add', '-A') + _git(repo, 'commit', '-qm', 'fix: initial') + _git(repo, 'remote', 'add', 'origin', str(remote)) + return repo, remote + + +def _run_create_tag(repo, script, new_tag): + """Run the Create tag step with minimal environment (no inherited vars).""" + env = { + 'PATH': os.environ.get('PATH', '/usr/bin:/bin'), + 'HOME': os.environ.get('HOME', '/tmp'), + 'NEW_TAG': new_tag, + } + return subprocess.run( + ['bash', '-c', script], cwd=repo, env=env, + capture_output=True, text=True, + ) + + +def test_create_tag_pushes_the_tag(repo_with_remote): + repo, remote = repo_with_remote + branches_before = set(_git(remote, 'branch').split()) + script = _step_script(_job('ci.yml', 'tag'), 'Create tag') + result = _run_create_tag(repo, script, 'v0.1.0') + assert result.returncode == 0, result.stderr + assert 'v0.1.0' in _git(remote, 'tag') + + # The step must push ONLY the tag. `git push origin "refs/tags/$NEW_TAG"` + # mutated to add `HEAD:main` (or to `git push origin --tags`, which would + # also push any branch refs the local repo happens to carry) would leave + # the tag assertion above passing while silently also moving/creating a + # branch on the remote -- something this job has no business doing; only + # main and nightly themselves are supposed to advance, and only by a real + # merge, never by this tagging step. + branches_after = set(_git(remote, 'branch').split()) + assert branches_after == branches_before, ( + f'Create tag step changed the remote branch set: ' + f'before={branches_before}, after={branches_after}' + ) + + +def test_create_tag_is_idempotent(repo_with_remote): + """A re-run of the workflow must not fail the job.""" + repo, _ = repo_with_remote + script = _step_script(_job('ci.yml', 'tag'), 'Create tag') + assert _run_create_tag(repo, script, 'v0.1.0').returncode == 0 + second = _run_create_tag(repo, script, 'v0.1.0') + assert second.returncode == 0, second.stderr + + +def test_an_empty_batch_tags_nothing_and_is_not_an_error(repo_with_remote): + """No commits since the last release is a re-run, not a failure. Tagging + "" would fail with a message about nothing in particular.""" + repo, remote = repo_with_remote + script = _step_script(_job('ci.yml', 'tag'), 'Create tag') + result = _run_create_tag(repo, script, '') + assert result.returncode == 0, result.stderr + assert _git(remote, 'tag').strip() == '' + + +def test_the_policy_module_agrees_with_this_repository(): + """End to end against the real repo: the CLI runs and prints a usable tag + or nothing at all. Catches an import error or a bad shebang that no unit + test would see.""" + result = subprocess.run( + ['python3', os.path.join(REPO_ROOT, 'tools', 'next_version.py'), + '--channel', 'nightly', '--repo-dir', REPO_ROOT], + capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + output = result.stdout.strip() + assert output == '' or re.match(r'^v\d+\.\d+\.\d+rc\d+$', output), output + + +# --- the hand-pushed release path ------------------------------------------- + + +def test_release_workflow_still_exists_for_hand_pushed_tags(): + """The policy never bumps a major automatically, so a major release is + `git tag v1.0.0 && git push`. This is what turns that into a release.""" + assert _triggers(_load('release.yml'))['push']['tags'] == ['v*'] + + +def test_the_release_workflow_uses_only_built_in_actions(): + """The runner already ships gh, and the tag job publishes the same way. + Assert positive: the release step must call `gh release create`, and no + step may use a third-party action.""" + job = list(_load('release.yml')['jobs'].values())[0] + + # Positive assertion: at least one step must run gh release create + gh_found = False + for step in job['steps']: + if 'gh release create' in step.get('run', ''): + gh_found = True + break + assert gh_found, 'no step runs `gh release create`' + + # Every step that uses an action must use built-in actions/ + for step in job['steps']: + uses = step.get('uses', '') + if uses: + assert uses.startswith('actions/'), \ + f'release workflow uses non-built-in action: {uses}' + + +# --- lint job scope ----------------------------------------------------- + + +def test_ruff_covers_every_python_source_directory(): + """The lint job's ruff invocation must check spoonmap.py, tests/, AND + tools/. tools/next_version.py ships this repo's release-versioning policy + and is exercised by tests/test_next_version.py just like spoonmap.py + itself; dropping `tools/` from the ruff command leaves 24 tests passing + with nothing checking it.""" + script = _step_script(_job('ci.yml', 'lint'), 'Ruff') + assert re.search(r'\bruff\s+check\b', script), script + for target in ('spoonmap.py', 'tests/', 'tools/'): + assert re.search(r'(? disk -> reload path, + not just the in-memory dict, since a key present in the dict but + dropped by json.dump, or never read back by _load_config, would pass + a weaker check.""" + cfg = _build_interactive_config( + 'All', [], 'All', True, False, 'Internal', '2000', + '/t/r', '/t/o', None, 5, 5, 5_000_000, True, + check_for_updates=True) + assert cfg['check_for_updates'] is True + path = tmp_path / 'config.json' + assert _write_interactive_config(str(path), cfg) is True + reloaded = _load_config(json.loads(path.read_text()), '/t') + assert reloaded['check_for_updates'] is True + + def test_check_for_updates_false_also_writes_explicitly(self): + """False must be written as an explicit key too, not merely omitted + -- an omitted key would happen to default to False on reload, which + would make this test pass for the wrong reason if the field were + dropped from `values` entirely.""" + cfg = _build_interactive_config( + 'All', [], 'All', True, False, 'Internal', '2000', + 'r', 'o', None, 5, 5, 5_000_000, True, + check_for_updates=False) + assert 'check_for_updates' in cfg + assert cfg['check_for_updates'] is False + def test_exclusions_none_becomes_empty_string(self): cfg = _build_interactive_config( 'All', [], 'All', True, False, 'Internal', '2000', @@ -2955,6 +3050,12 @@ def test_every_sample_doc_key_is_in_the_constant(self): def test_sample_has_no_generated_marker(self): assert _CONFIG_GENERATED_KEY not in self._sample() + def test_check_for_updates_defaults_to_false_in_sample(self): + """The security model requires this key to default false. Flipping it + in the sample is the most likely route to it being enabled by accident. + This test pins the value permanently.""" + assert self._sample()['check_for_updates'] is False + def test_scan_categories_choices_track_service_categories(self): choices = dict(_CONFIG_DOCS['scan_categories'])['__scan_categories_choices__'] assert choices == 'All, Full, ' + ', '.join(SERVICE_CATEGORIES) @@ -12476,3 +12577,266 @@ def test_cleanup_removes_the_record(self, tmp_path): assert (disc / 'portFull.xml.coverage').exists() spoonmap._delete_previous_results(str(tmp_path)) assert not (tmp_path / 'discovery').exists() + + +class TestUpdateCheckIsOptIn: + """The launch-time update check is off unless explicitly enabled. + + SpooNMAP runs from jumpboxes inside client networks, where an unprompted + call to api.github.com is an outbound beacon from an engagement host that + nobody authorised. hate_crack defaults this to True; SpooNMAP inverts it, + and these tests are what keep it inverted. + """ + + def test_a_config_that_never_mentions_the_key_makes_no_network_call(self): + with patch('spoonmap._check_for_updates') as checked: + spoonmap._maybe_check_for_updates(False) + assert not checked.called, 'a default config performed a network call at launch' + + def test_enabling_it_performs_the_check(self): + with patch('spoonmap._check_for_updates') as checked: + spoonmap._maybe_check_for_updates(True) + checked.assert_called_once() + + def test_load_config_defaults_the_key_to_false(self): + cfg = _config_dict() + assert 'check_for_updates' not in cfg + assert _load_config(cfg, '/t')['check_for_updates'] is False + + def test_load_config_honours_an_explicit_true(self): + cfg = _config_dict(check_for_updates=True) + assert _load_config(cfg, '/t')['check_for_updates'] is True + + def test_load_config_accepts_the_legacy_quoted_spelling(self): + """_config_bool() accepts "True"/"False" indefinitely for hand-edited + configs; this key is no exception.""" + cfg = _config_dict(check_for_updates='True') + assert _load_config(cfg, '/t')['check_for_updates'] is True + + +class TestCheckForUpdates: + """The check itself: comparison, output, and total failure tolerance.""" + + def _response(self, tag): + body = json.dumps({'tag_name': tag}).encode() + resp = MagicMock() + resp.read.return_value = body + resp.__enter__.return_value = resp + return resp + + def test_a_newer_release_is_reported(self, capsys): + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')): + spoonmap._check_for_updates() + out = capsys.readouterr().out + assert 'Update available' in out + assert '0.1.0' in out + + def test_being_up_to_date_says_so_without_claiming_an_update(self, capsys): + with patch('spoonmap._tool_version', return_value='0.1.0'), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')): + spoonmap._check_for_updates() + out = capsys.readouterr().out + assert 'Update available' not in out + assert 'is up to date' in out + + def test_an_older_release_is_not_an_update(self, capsys): + with patch('spoonmap._tool_version', return_value='0.2.0'), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')): + spoonmap._check_for_updates() + assert 'Update available' not in capsys.readouterr().out + + def test_an_unknown_local_version_never_claims_an_update(self, capsys): + """Running from a checkout has no version to compare. Reporting the + latest release is fine; asserting the operator is behind is not -- + it would nag everyone running from a clone, which is most of them.""" + with patch('spoonmap._tool_version', + return_value=spoonmap._UNKNOWN_VERSION), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')): + spoonmap._check_for_updates() + out = capsys.readouterr().out + assert 'Update available' not in out + assert '0.1.0' in out + + def test_a_network_failure_is_swallowed(self, capsys): + """A failed update check must never delay, prompt, or abort a scan.""" + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', + side_effect=OSError('no route to host')): + spoonmap._check_for_updates() # must not raise + assert 'Update available' not in capsys.readouterr().out + + def test_unparseable_json_is_swallowed(self, capsys): + resp = MagicMock() + resp.read.return_value = b'404' + resp.__enter__.return_value = resp + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', return_value=resp): + spoonmap._check_for_updates() # must not raise + assert 'Update available' not in capsys.readouterr().out + + def test_a_release_with_no_tag_name_is_swallowed(self, capsys): + resp = MagicMock() + resp.read.return_value = b'{}' + resp.__enter__.return_value = resp + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', return_value=resp): + spoonmap._check_for_updates() # must not raise + + def test_non_string_tag_name_is_coerced(self, capsys): + """A proxy or captive portal might return well-formed JSON with + unexpected field types. Non-string tag_name must not crash.""" + for tag_value in (123, {'nested': 'dict'}): + body = json.dumps({'tag_name': tag_value}).encode() + resp = MagicMock() + resp.read.return_value = body + resp.__enter__.return_value = resp + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', return_value=resp): + spoonmap._check_for_updates() # must not raise + assert 'Update available' not in capsys.readouterr().out + + def test_json_body_that_is_not_a_dict_is_swallowed(self, capsys): + """A proxy or captive portal might return valid JSON that is not an + object — a list, or a bare string. These are common error page + responses and must not crash.""" + for body_bytes in (b'[]', b'"nope"'): + resp = MagicMock() + resp.read.return_value = body_bytes + resp.__enter__.return_value = resp + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', return_value=resp): + spoonmap._check_for_updates() # must not raise + assert 'Update available' not in capsys.readouterr().out + + def test_a_non_exact_local_version_says_not_comparable_not_unknown(self, capsys): + """An rc-tag or .postN.devN install has a real, precise version -- + --version prints it -- so calling it "unknown" here would contradict + --version's own output. It must say the local version is not + comparable to a release, and still make no claim about being + behind.""" + with patch('spoonmap._tool_version', return_value='0.1.0rc1'), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')): + spoonmap._check_for_updates() + out = capsys.readouterr().out + assert 'Update available' not in out + assert 'unknown' not in out + assert '0.1.0rc1' in out + assert 'not comparable' in out + + # --- quiet vs. on-demand failure reporting (default quiet=True is the + # launch-time contract; --check-update passes quiet=False) -------------- + + def test_launch_time_failure_is_silent_by_default(self, capsys): + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', + side_effect=OSError('no route to host')): + spoonmap._check_for_updates() # quiet=True by default + assert capsys.readouterr().out == '' + + def test_on_demand_failure_is_reported(self, capsys): + """--check-update passes quiet=False. Silence here is indistinguishable + from "you are up to date", and is what api.github.com's 404 (no + releases published yet) produces on every single invocation today.""" + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', + side_effect=OSError('no route to host')): + spoonmap._check_for_updates(quiet=False) + out = capsys.readouterr().out + assert out.strip() != '' + assert 'Update check failed' in out + + def test_on_demand_http_error_names_the_status_code(self, capsys): + error = urllib.error.HTTPError( + spoonmap._RELEASE_API_URL, 404, 'Not Found', {}, None) + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', side_effect=error): + spoonmap._check_for_updates(quiet=False) + assert '404' in capsys.readouterr().out + + def test_on_demand_url_error_names_the_reason(self, capsys): + error = urllib.error.URLError('no route to host') + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', side_effect=error): + spoonmap._check_for_updates(quiet=False) + assert 'no route to host' in capsys.readouterr().out + + def test_on_demand_non_dict_payload_is_reported(self, capsys): + resp = MagicMock() + resp.read.return_value = b'[]' + resp.__enter__.return_value = resp + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', return_value=resp): + spoonmap._check_for_updates(quiet=False) + assert 'Update check failed' in capsys.readouterr().out + + def test_on_demand_unparseable_tag_is_reported(self, capsys): + resp = self._response('not-a-version') + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', return_value=resp): + spoonmap._check_for_updates(quiet=False) + out = capsys.readouterr().out + assert 'Update check failed' in out + assert 'not-a-version' in out + + # --- network-safety properties that must have real assertions behind + # them, not just comments (item 9) --------------------------------------- + + def test_urlopen_is_called_with_a_timeout(self): + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')) as mock_urlopen: + spoonmap._check_for_updates() + _, kwargs = mock_urlopen.call_args + assert 'timeout' in kwargs, 'urlopen must be called with an explicit timeout' + assert kwargs['timeout'] == spoonmap._UPDATE_CHECK_TIMEOUT + + def test_the_timeout_constant_is_small(self): + """A courtesy check that runs before every scan must not become a + stalled engagement. Sane upper bound, not the exact value, so the + constant can still be tuned without this test becoming brittle.""" + assert 0 < spoonmap._UPDATE_CHECK_TIMEOUT <= 30 + + def test_the_url_requests_the_latest_release_specifically(self): + """/releases (plural, no /latest) would include pre-releases, which + would advertise a nightly rc candidate as an available update to + every operator -- exactly what _RELEASE_API_URL's own comment says + must not happen.""" + assert spoonmap._RELEASE_API_URL.rstrip('/').endswith('/releases/latest') + + # Pinning the constant alone doesn't prove _check_for_updates() ever + # uses it -- a mutation that inlined a different literal URL at the + # urlopen() call site would leave the assertion above passing. Assert + # the actual call used the constant. + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')) as mock_urlopen: + spoonmap._check_for_updates() + assert mock_urlopen.call_args[0][0] == spoonmap._RELEASE_API_URL + + +class TestParseReleaseTag: + """Version comparison, without a packaging dependency.""" + + @pytest.mark.parametrize('text,expected', [ + ('v0.1.0', (0, 1, 0)), + ('0.1.0', (0, 1, 0)), + ('v10.4.7', (10, 4, 7)), + # Not a comparable release: a candidate, a dev build, junk, and the + # running-from-source sentinel. + ('v0.1.0rc1', None), + ('0.0.1.post1.dev1', None), + ('nightly', None), + ('', None), + ]) + def test_only_plain_releases_compare(self, text, expected): + assert spoonmap._parse_release_tag(text) == expected + + def test_comparison_is_numeric_not_lexical(self): + assert (spoonmap._parse_release_tag('v0.10.0') + > spoonmap._parse_release_tag('v0.9.0')) diff --git a/tools/next_version.py b/tools/next_version.py new file mode 100644 index 0000000..c30c0a3 --- /dev/null +++ b/tools/next_version.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Compute the next version tag for a branch, per SpooNMAP's release policy. + +The policy is ordinary semver, with the bump derived from what is actually in +the batch: + +* **The second component moves only for features.** Any ``feat`` commit since + the last release means the batch is heading for ``X.(Y+1).0``. A batch of + nothing but fixes, docs and chores is heading for ``X.Y.(Z+1)``. +* **``nightly`` cuts release candidates** for whichever version the batch is + heading toward: ``v2.20.1rc1``, ``v2.20.1rc2``, … These are real PEP 440 + pre-releases, so they sort *above* the release that precedes them and *below* + the release they become:: + + 2.20.0 < 2.20.1rc1 < 2.20.1rc2 < 2.20.1 < 2.21.0rc1 < 2.21.0 + + That ordering is the whole point of targeting the *next* version rather than + the current one. +* **``main`` cuts the final** of that same target. Merging ``nightly`` down + promotes the candidate: a fix-only cycle ends at ``2.20.1``, a cycle with a + feature ends at ``2.21.0``. + +The major component is never bumped automatically. A ``!`` subject or a +``BREAKING CHANGE:`` footer is treated as a feature here, because an automatic +major is an irreversible published mistake waiting for one mistyped subject +line; a major release stays an explicit human act (tag and push it by hand). + +Baseline is the highest final tag in the repository, deliberately NOT restricted +to tags reachable from HEAD. ``main``'s release tag can sit on a commit that the +``nightly`` tip does not contain, and a reachability-restricted lookup would +then compute the next nightly from a stale baseline and hand out a version below +the release that already shipped. + +This policy assumes ``main`` contains ``nightly``'s commits as ancestors -- +merge or fast-forward ``nightly`` into ``main``, never squash. A squash merge +collapses ``nightly``'s already-released commits into one commit that is not +reachable from any prior tag, so ``git log ..HEAD`` on ``main`` +re-lists them on every subsequent push, forever. Versions still come out +monotonic (there is always at least the squash commit itself since the last +release), so this fails quietly rather than loudly: nothing errors, the +computed target is just wrong. + +Everything above the git boundary is pure and unit-tested in +tests/test_next_version.py. The ``tag`` job in ``.github/workflows/ci.yml`` +calls this for both channels so the policy lives in exactly one place, +expressed in Python where it can be tested rather than in YAML where it +cannot. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from typing import List, Optional, Tuple + +# A released version: exactly vX.Y.Z. Anything with a pre-release, post-release +# or local segment is deliberately excluded -- the baseline must be a version +# that actually shipped. +FINAL_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") + +# A candidate this policy produces: vX.Y.ZrcN. +RC_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)rc(\d+)$") + +# A conventional-commit subject introducing a feature: `feat:`, `feat(scope):`, +# and the breaking forms `feat!:` / `feat(scope)!:`. Anchored at the start of the +# subject so a `feat` mentioned mid-sentence in a fix's body cannot promote the +# whole batch to a minor bump. +FEATURE_SUBJECT = re.compile(r"^feat(\([^)]*\))?!?:", re.IGNORECASE) + +# A breaking change under conventional commits: any type with `!` before the +# colon, or a `BREAKING CHANGE:` footer. Treated as a feature, not a major -- +# see the module docstring. +BREAKING = re.compile( + r"^[a-z]+(\([^)]*\))?!:|^BREAKING[ -]CHANGE:", re.IGNORECASE | re.MULTILINE +) + +# Evaluated at import, so it cannot use PEP 585 builtin generics: the whole +# suite runs on 3.8 in the `test-legacy` CI job. +Version = Tuple[int, int, int] + + +def parse_final(tag: str) -> Optional[Version]: + """``(major, minor, patch)`` for a released tag, else ``None``.""" + match = FINAL_TAG.match(tag.strip()) + if not match: + return None + return (int(match[1]), int(match[2]), int(match[3])) + + +def latest_final(tags: List[str]) -> Version: + """Highest released version among *tags*, or ``(0, 0, 0)`` if there is none. + + ``(0, 0, 0)`` means "nothing has shipped yet", which makes the first fix-only + batch 0.0.1 and the first batch with a feature 0.1.0. + """ + finals = [v for v in (parse_final(tag) for tag in tags) if v is not None] + return max(finals) if finals else (0, 0, 0) + + +def has_feature(messages: List[str]) -> bool: + """Does any commit in *messages* introduce a feature (or break something)? + + Each element is a whole commit message, so the subject is its first line; + the ``BREAKING CHANGE:`` footer is matched anywhere in the body. + """ + for message in messages: + subject = message.strip().splitlines()[0] if message.strip() else "" + if FEATURE_SUBJECT.match(subject.strip()): + return True + if BREAKING.search(message): + return True + return False + + +def target_version(base: Version, messages: List[str]) -> Optional[Version]: + """The version this batch is heading for, or ``None`` if it is empty. + + ``None`` is not an error: a re-run of a workflow on an already-tagged commit + has no commits since the baseline, and the right answer there is "nothing to + tag" rather than a version nobody asked for. + """ + if not messages: + return None + major, minor, patch = base + if has_feature(messages): + return (major, minor + 1, 0) + return (major, minor, patch + 1) + + +def next_rc_number(target: Version, tags: List[str]) -> int: + """The next candidate number for *target*: one above the highest seen. + + Counts from the tags rather than from a stored counter so a deleted or + re-pushed tag cannot make this hand out a number that is already taken. + """ + highest = 0 + for tag in tags: + match = RC_TAG.match(tag.strip()) + if not match: + continue + if (int(match[1]), int(match[2]), int(match[3])) == target: + highest = max(highest, int(match[4])) + return highest + 1 + + +def format_version(version: Version) -> str: + return f"v{version[0]}.{version[1]}.{version[2]}" + + +def compute(channel: str, tags: List[str], messages: List[str]) -> Optional[str]: + """The tag to create for *channel*, or ``None`` when there is nothing to tag. + + Pure: every input is passed in, so the whole policy is testable without a + repository. ``stable`` is ``main``'s final release; ``nightly`` is the + candidate heading for the same target. + """ + if channel not in ("stable", "nightly"): + raise ValueError(f"unknown channel {channel!r}") + target = target_version(latest_final(tags), messages) + if target is None: + return None + if channel == "stable": + return format_version(target) + return f"{format_version(target)}rc{next_rc_number(target, tags)}" + + +# --------------------------------------------------------------------------- +# git boundary -- the only impure part, kept as thin as possible +# --------------------------------------------------------------------------- + + +def _git(args: List[str], repo_dir: str) -> str: + result = subprocess.run( + ["git", *args], + cwd=repo_dir, + capture_output=True, + text=True, + check=True, + ) + return result.stdout + + +def git_tags(repo_dir: str) -> List[str]: + """Every tag in the repository, unordered. + + Ordering is this module's job, not git's: asking git to sort would put the + policy back in a shell pipeline, which is what having this file avoids. + """ + return [line for line in _git(["tag"], repo_dir).splitlines() if line.strip()] + + +def commit_messages(repo_dir: str, base: Version) -> List[str]: + """Whole commit messages on HEAD since the *base* release, newest first. + + A ``(0, 0, 0)`` base means nothing has shipped, so the entire history counts. + NUL-delimited because a commit body contains blank lines and any line-based + split would chop one message into several. + """ + rev_range = "HEAD" if base == (0, 0, 0) else f"{format_version(base)}..HEAD" + out = _git(["log", rev_range, "--format=%B%x00"], repo_dir) + return [chunk for chunk in out.split("\0") if chunk.strip()] + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--channel", required=True, choices=["stable", "nightly"]) + parser.add_argument("--repo-dir", default=".") + args = parser.parse_args(argv) + + tags = git_tags(args.repo_dir) + messages = commit_messages(args.repo_dir, latest_final(tags)) + tag = compute(args.channel, tags, messages) + if tag is None: + return 0 + print(tag) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index ac7c673..b681fca 100644 --- a/uv.lock +++ b/uv.lock @@ -147,7 +147,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -363,14 +363,15 @@ wheels = [ [[package]] name = "spoonmap" -version = "0.1.0" source = { editable = "." } [package.dev-dependencies] dev = [ { name = "bandit", extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, { name = "pytest", marker = "python_full_version >= '3.10'" }, { name = "pytest-cov", marker = "python_full_version >= '3.10'" }, + { name = "pyyaml", marker = "python_full_version >= '3.10'" }, { name = "ruff", marker = "python_full_version >= '3.10'" }, ] @@ -379,8 +380,10 @@ dev = [ [package.metadata.requires-dev] dev = [ { name = "bandit", extras = ["toml"], specifier = "==1.9.4" }, + { name = "packaging", specifier = ">=24.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", specifier = "==0.16.4" }, ]