Skip to content

Commit 8ccb407

Browse files
committed
chore(release): simplify release info updater
1 parent 746a818 commit 8ccb407

2 files changed

Lines changed: 61 additions & 53 deletions

File tree

scripts/update_release_info.py

Lines changed: 61 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
from typing import Any
1818

1919
DOMAINS = ("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
2124
REQUIRED_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+
153181
def 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

248265
def version_from_tag(tag: str, tag_prefix: str) -> str:

uv.lock

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)