1717from typing import Any
1818
1919DOMAINS = ("enterprise" , "mobile" , "ics" )
20- RELEASE_INFO_PATH = Path ("mitreattack/release_info.py" )
20+ SCRIPT_PATH = Path (__file__ ).resolve ()
21+ REPO_ROOT = SCRIPT_PATH .parents [1 ]
22+ RELEASE_INFO_DISPLAY_PATH = Path ("mitreattack/release_info.py" )
23+ RELEASE_INFO_PATH = REPO_ROOT / RELEASE_INFO_DISPLAY_PATH
2124REQUIRED_ASSIGNMENTS = ("LATEST_VERSION" , "STIX20" , "STIX21" )
2225
2326
@@ -54,35 +57,27 @@ def main() -> None:
5457 """Parse arguments and update release_info.py."""
5558 parser = argparse .ArgumentParser (description = __doc__ )
5659 parser .add_argument ("version" , nargs = "?" , help = "ATT&CK release version, for example 19.1" )
57- parser .add_argument (
58- "--release-info" ,
59- type = Path ,
60- default = RELEASE_INFO_PATH ,
61- help = f"Path to release_info.py. Defaults to { RELEASE_INFO_PATH } " ,
62- )
63- parser .add_argument ("--token" , default = None , help = "GitHub token. Defaults to GITHUB_TOKEN if set." )
64- parser .add_argument ("--dry-run" , action = "store_true" , help = "Print the updated file instead of writing it." )
65- parser .add_argument ("--no-format" , action = "store_true" , help = "Skip running ruff format after writing." )
60+ parser .add_argument ("--dry-run" , action = "store_true" , help = "Print a summary of updates instead of writing." )
6661 args = parser .parse_args ()
6762
68- version = args .version or fetch_latest_common_version (token = args .token )
69- hashes = fetch_release_hashes (version = version , token = args .token )
70- updated = update_release_info_source (args .release_info .read_text (), version = version , release_hashes = hashes )
63+ version = args .version or fetch_latest_common_version ()
64+ hashes = fetch_release_hashes (version = version )
65+ source = RELEASE_INFO_PATH .read_text ()
66+ updated = update_release_info_source (source , version = version , release_hashes = hashes )
7167
7268 if args .dry_run :
73- print (updated )
69+ print (format_dry_run_summary ( source , version = version , release_hashes = hashes ) )
7470 return
7571
76- args .release_info .write_text (updated )
77- if not args .no_format :
78- subprocess .run (["uv" , "run" , "--extra" , "dev" , "ruff" , "format" , str (args .release_info )], check = True )
72+ RELEASE_INFO_PATH .write_text (updated )
73+ subprocess .run (["uv" , "run" , "--extra" , "dev" , "ruff" , "format" , str (RELEASE_INFO_PATH )], check = True , cwd = REPO_ROOT )
7974
80- print (f"Updated { args . release_info } for ATT&CK v{ version } " )
75+ print (f"Updated { RELEASE_INFO_DISPLAY_PATH } for ATT&CK v{ version } " )
8176
8277
83- def fetch_latest_common_version (token : str | None = None ) -> str :
78+ def fetch_latest_common_version () -> str :
8479 """Fetch the latest non-prerelease version present in both STIX release repos."""
85- latest_versions = {source .stix_version : fetch_latest_version (source , token = token ) for source in RELEASE_SOURCES }
80+ latest_versions = {source .stix_version : fetch_latest_version (source ) for source in RELEASE_SOURCES }
8681 if latest_versions ["2.0" ] != latest_versions ["2.1" ]:
8782 raise SystemExit (
8883 "Latest STIX release versions do not match: "
@@ -92,25 +87,25 @@ def fetch_latest_common_version(token: str | None = None) -> str:
9287 return latest_versions ["2.0" ]
9388
9489
95- def fetch_latest_version (source : ReleaseSource , token : str | None = None ) -> str :
90+ def fetch_latest_version (source : ReleaseSource ) -> str :
9691 """Fetch the latest GitHub release version for one STIX source."""
97- release = github_json (f"https://api.github.com/repos/{ source .owner } /{ source .repo } /releases/latest" , token = token )
92+ release = github_json (f"https://api.github.com/repos/{ source .owner } /{ source .repo } /releases/latest" )
9893 return version_from_tag (release ["tag_name" ], source .tag_prefix )
9994
10095
101- def fetch_release_hashes (version : str , token : str | None = None ) -> dict [str , dict [str , str ]]:
96+ def fetch_release_hashes (version : str ) -> dict [str , dict [str , str ]]:
10297 """Fetch SHA256 hashes for every required STIX source and domain."""
10398 release_hashes : dict [str , dict [str , str ]] = {}
10499 for source in RELEASE_SOURCES :
105- release_hashes [source .assignment_name ] = fetch_source_hashes (source , version = version , token = token )
100+ release_hashes [source .assignment_name ] = fetch_source_hashes (source , version = version )
106101 return release_hashes
107102
108103
109- def fetch_source_hashes (source : ReleaseSource , version : str , token : str | None = None ) -> dict [str , str ]:
104+ def fetch_source_hashes (source : ReleaseSource , version : str ) -> dict [str , str ]:
110105 """Fetch SHA256 hashes for one STIX release source."""
111106 tag = f"{ source .tag_prefix } { version } "
112107 url = f"https://api.github.com/repos/{ source .owner } /{ source .repo } /releases/tags/{ quote_tag (tag )} "
113- release = github_json (url , token = token )
108+ release = github_json (url )
114109 assets = release .get ("assets" , [])
115110 hashes : dict [str , str ] = {}
116111
@@ -124,7 +119,7 @@ def fetch_source_hashes(source: ReleaseSource, version: str, token: str | None =
124119 browser_download_url = asset .get ("browser_download_url" )
125120 if not isinstance (browser_download_url , str ):
126121 raise SystemExit (f"Missing browser_download_url for { source .owner } /{ source .repo } { tag } { asset .get ('name' )} " )
127- hashes [domain ] = fetch_sha256 (browser_download_url , token = token )
122+ hashes [domain ] = fetch_sha256 (browser_download_url )
128123
129124 return hashes
130125
@@ -150,6 +145,39 @@ def update_release_info_source(source: str, version: str, release_hashes: dict[s
150145 return replace_assignments (source , assignments , replacements )
151146
152147
148+ def format_dry_run_summary (source : str , version : str , release_hashes : dict [str , dict [str , str ]]) -> str :
149+ """Return a targeted summary of release_info.py changes without printing the full file."""
150+ tree = ast .parse (source )
151+ assignments = find_assignments (tree )
152+ latest_version = ast .literal_eval (assignments ["LATEST_VERSION" ].value )
153+ stix_values = {
154+ "STIX20" : ast .literal_eval (assignments ["STIX20" ].value ),
155+ "STIX21" : ast .literal_eval (assignments ["STIX21" ].value ),
156+ }
157+
158+ lines = [f"Would update { RELEASE_INFO_DISPLAY_PATH } for ATT&CK v{ version } " , "LATEST_VERSION:" ]
159+ if latest_version == version :
160+ lines .append (f' unchanged LATEST_VERSION = "{ version } "' )
161+ else :
162+ lines .append (f'- LATEST_VERSION = "{ latest_version } "' )
163+ lines .append (f'+ LATEST_VERSION = "{ version } "' )
164+
165+ for assignment_name , domain_hashes in release_hashes .items ():
166+ lines .append (f"{ assignment_name } :" )
167+ for domain in DOMAINS :
168+ current_hash = stix_values [assignment_name ].get (domain , {}).get (version )
169+ new_hash = domain_hashes [domain ]
170+ if current_hash is None :
171+ lines .append (f"+ { assignment_name } .{ domain } [{ version !r} ] = { new_hash !r} " )
172+ elif current_hash == new_hash :
173+ lines .append (f" unchanged { assignment_name } .{ domain } [{ version !r} ] = { new_hash !r} " )
174+ else :
175+ lines .append (f"- { assignment_name } .{ domain } [{ version !r} ] = { current_hash !r} " )
176+ lines .append (f"+ { assignment_name } .{ domain } [{ version !r} ] = { new_hash !r} " )
177+
178+ return "\n " .join (lines )
179+
180+
153181def find_assignments (tree : ast .Module ) -> dict [str , ast .Assign ]:
154182 """Find required top-level assignment nodes."""
155183 assignments : dict [str , ast .Assign ] = {}
@@ -196,9 +224,9 @@ def find_domain_asset(assets: list[dict[str, Any]], domain: str, version: str) -
196224 )
197225
198226
199- def github_json (url : str , token : str | None = None ) -> Any :
227+ def github_json (url : str ) -> Any :
200228 """Fetch JSON from the GitHub API."""
201- request = urllib .request .Request (url , headers = github_headers (token ))
229+ request = urllib .request .Request (url , headers = github_headers ())
202230 try :
203231 with urllib .request .urlopen (request ) as response :
204232 return json .loads (response .read ().decode ("utf-8" ))
@@ -208,11 +236,11 @@ def github_json(url: str, token: str | None = None) -> Any:
208236 raise SystemExit (f"GitHub API request failed for { url } : { error .reason } " ) from error
209237
210238
211- def fetch_sha256 (url : str , token : str | None = None ) -> str :
239+ def fetch_sha256 (url : str ) -> str :
212240 """Download an asset and return its SHA256 hash."""
213241 import hashlib
214242
215- request = urllib .request .Request (url , headers = github_headers (token ))
243+ request = urllib .request .Request (url , headers = github_headers ())
216244 sha256_hash = hashlib .sha256 ()
217245 try :
218246 with urllib .request .urlopen (request ) as response :
@@ -225,24 +253,13 @@ def fetch_sha256(url: str, token: str | None = None) -> str:
225253 return sha256_hash .hexdigest ()
226254
227255
228- def github_headers (token : str | None = None ) -> dict [str , str ]:
256+ def github_headers () -> dict [str , str ]:
229257 """Build GitHub request headers."""
230- headers = {
258+ return {
231259 "Accept" : "application/vnd.github+json" ,
232260 "User-Agent" : "mitreattack-python-release-info-updater" ,
233261 "X-GitHub-Api-Version" : "2022-11-28" ,
234262 }
235- token = token or env_github_token ()
236- if token :
237- headers ["Authorization" ] = f"Bearer { token } "
238- return headers
239-
240-
241- def env_github_token () -> str | None :
242- """Return GITHUB_TOKEN from the environment without importing os at module import time."""
243- import os
244-
245- return os .environ .get ("GITHUB_TOKEN" )
246263
247264
248265def version_from_tag (tag : str , tag_prefix : str ) -> str :
0 commit comments