Files
2026-05-23 15:11:48 +09:00

942 lines
36 KiB
Python

#!/usr/bin/env python3
"""Utilities shared by ta4j release GitHub Actions workflows."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import pathlib
import re
import subprocess
import sys
import textwrap
import urllib.request
from urllib.parse import urlparse
import xml.etree.ElementTree as ET
from typing import Any
SECRET_PATTERNS = (
(re.compile(r"https?://\S+"), "[REDACTED_URL]"),
(re.compile(r"gh[oprsu]_[A-Za-z0-9_]{20,}"), "[REDACTED_TOKEN]"),
(
re.compile(r"(?<![A-Za-z0-9_])(?=[A-Za-z0-9+/=_-]{32,})(?=.*[0-9])[A-Za-z0-9+/=_-]{32,}"),
"[REDACTED_SECRET]",
),
)
EXPECTED_RELEASE_ARTIFACTS = (
"ta4j-core/target/ta4j-core-{version}.jar",
"ta4j-core/target/ta4j-core-{version}-sources.jar",
"ta4j-core/target/ta4j-core-{version}-javadoc.jar",
"ta4j-core/target/ta4j-core-{version}-tests.jar",
"ta4j-examples/target/ta4j-examples-{version}.jar",
"ta4j-examples/target/ta4j-examples-{version}-sources.jar",
"ta4j-examples/target/ta4j-examples-{version}-javadoc.jar",
)
JAVADOC_PATH_PATTERN = re.compile(r"^.*?(ta4j-(?:core|examples)/src/.+)$")
SNAPSHOT_METADATA_URL = "https://central.sonatype.com/repository/maven-snapshots/org/ta4j/ta4j-parent/maven-metadata.xml"
SNAPSHOT_WORKFLOW_NAME = "Publish Snapshot to Maven Central"
def redact(value: str) -> str:
redacted = value
for pattern, replacement in SECRET_PATTERNS:
redacted = pattern.sub(replacement, redacted)
return redacted
def redact_log_path(value: str) -> str:
redacted = value
for pattern, replacement in SECRET_PATTERNS[:2]:
redacted = pattern.sub(replacement, redacted)
return redacted
def run_git(args: list[str], *, check: bool = True) -> str:
completed = subprocess.run(
["git", *args],
check=check,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
return completed.stdout
def write_json(path: pathlib.Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def write_text(path: pathlib.Path, value: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(value, encoding="utf-8")
def append_output(name: str, value: str, output_path: str | None = None) -> None:
target = output_path or os.environ.get("GITHUB_OUTPUT")
if not target:
return
with open(target, "a", encoding="utf-8") as handle:
if "\n" in value:
delimiter = f"EOF_{name}_{os.getpid()}"
handle.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n")
else:
handle.write(f"{name}={value}\n")
def load_catalog(args: argparse.Namespace) -> list[dict[str, Any]]:
if args.catalog_file:
with open(args.catalog_file, "r", encoding="utf-8") as handle:
data = json.load(handle)
else:
request = urllib.request.Request(
args.catalog_url,
headers={
"Accept": "application/json",
"User-Agent": "ta4j-release-automation",
},
)
with urllib.request.urlopen(request, timeout=args.timeout_seconds) as response:
data = json.load(response)
if not isinstance(data, list):
raise ValueError("model catalog response must be a JSON array")
return data
def command_catalog_preflight(args: argparse.Namespace) -> int:
model_id = args.model.strip()
if not model_id:
raise ValueError("--model is required")
catalog = load_catalog(args)
selected = next((entry for entry in catalog if entry.get("id") == model_id), None)
if selected is None:
available = ", ".join(sorted(str(entry.get("id")) for entry in catalog if entry.get("id")))
print(f"::error::Configured RELEASE_AI_MODEL '{model_id}' was not found in the GitHub Models catalog.", file=sys.stderr)
print(f"Available models: {available}", file=sys.stderr)
return 1
limits = selected.get("limits") if isinstance(selected.get("limits"), dict) else {}
max_input_tokens = str(limits.get("max_input_tokens", ""))
max_output_tokens = str(limits.get("max_output_tokens", ""))
result = {
"id": selected.get("id", model_id),
"name": selected.get("name", ""),
"publisher": selected.get("publisher", ""),
"summary": selected.get("summary", ""),
"rate_limit_tier": selected.get("rate_limit_tier", ""),
"max_input_tokens": max_input_tokens,
"max_output_tokens": max_output_tokens,
"html_url": selected.get("html_url", ""),
}
write_json(args.output, result)
append_output("model_id", str(result["id"]))
append_output("model_name", str(result["name"]))
append_output("model_summary", str(result["summary"]))
append_output("model_rate_limit_tier", str(result["rate_limit_tier"]))
append_output("model_max_input_tokens", max_input_tokens)
append_output("model_max_output_tokens", max_output_tokens)
append_output("model_html_url", str(result["html_url"]))
print(
"audit:model_catalog_preflight "
f"model={result['id']} max_input_tokens={max_input_tokens or 'unknown'} "
f"max_output_tokens={max_output_tokens or 'unknown'} rate_limit_tier={result['rate_limit_tier'] or 'unknown'}"
)
return 0
def changed_files_since(last_tag: str) -> list[str]:
if last_tag == "none":
output = run_git(["ls-tree", "-r", "--name-only", "HEAD"])
else:
output = run_git(["diff", "--name-only", f"{last_tag}..HEAD"])
return sorted(path for path in output.splitlines() if path)
def category_for(path: str) -> str:
if path == "pom.xml" or path.endswith("/pom.xml"):
return "build metadata"
if "/src/main/" in f"/{path}":
return "production code"
if "/src/test/" in f"/{path}":
return "tests"
if path.startswith(".github/workflows/"):
return "workflows"
if path.startswith("scripts/"):
return "release/tooling scripts" if "release" in path or path.startswith("scripts/tests/") else "scripts"
if path == "CHANGELOG.md" or path.startswith("release/") or path == "RELEASE_PROCESS.md" or path == "README.md":
return "release documentation"
return "other"
def extract_unreleased_changelog() -> str:
path = pathlib.Path("CHANGELOG.md")
if not path.exists():
return "(CHANGELOG.md not found)"
lines = path.read_text(encoding="utf-8").splitlines()
captured: list[str] = []
in_section = False
for line in lines:
if re.match(r"^##\s+\[?Unreleased\]?", line):
in_section = True
continue
if in_section and line.startswith("## "):
break
if in_section:
captured.append(line)
text = "\n".join(captured).strip()
return text or "(Unreleased section is empty)"
def collect_diff(last_tag: str, paths: list[str], max_chars: int) -> tuple[str, bool]:
if last_tag == "none" or not paths:
return "(No prior release tag diff available.)", False
priority = sorted(
paths,
key=lambda path: (
0 if category_for(path) in {"production code", "build metadata"} else 1,
path,
),
)
try:
diff = run_git(["diff", "--no-ext-diff", "--find-renames", "--unified=80", f"{last_tag}..HEAD", "--", *priority])
except subprocess.CalledProcessError as exc:
diff = exc.stdout + "\n" + exc.stderr
redacted = redact(diff)
if len(redacted) > max_chars:
return redacted[:max_chars] + "\n\n[TRUNCATED: selected diff exceeded dossier budget]\n", True
return redacted, False
def public_api_signals(diff_text: str) -> list[str]:
signals: list[str] = []
pattern = re.compile(
r"^[+-]\s*(?:public|protected)\s+(?:static\s+|final\s+|abstract\s+|default\s+|sealed\s+|non-sealed\s+)*"
r"(?:class|interface|enum|record|@interface|[A-Za-z0-9_<>\[\], ?]+)\s+[A-Za-z0-9_]+",
re.MULTILINE,
)
for match in pattern.finditer(diff_text):
line = match.group(0).strip()
if line not in signals:
signals.append(line)
if len(signals) >= 80:
signals.append("[TRUNCATED: more public API signal lines omitted]")
break
return signals
def javadoc_signals(diff_text: str) -> list[str]:
signals: list[str] = []
for line in diff_text.splitlines():
stripped = line.strip()
if not stripped.startswith(("+", "-")):
continue
if "@since" in stripped or "/**" in stripped or stripped.startswith(("+ *", "- *")):
signals.append(stripped)
if len(signals) >= 80:
signals.append("[TRUNCATED: more Javadoc signal lines omitted]")
break
return signals
def command_build_dossier(args: argparse.Namespace) -> int:
files = changed_files_since(args.last_tag)
categories: dict[str, list[str]] = {}
for path in files:
categories.setdefault(category_for(path), []).append(path)
diff_text, diff_truncated = collect_diff(args.last_tag, files, args.max_diff_chars)
api_signals = public_api_signals(diff_text)
doc_signals = javadoc_signals(diff_text)
test_files = [path for path in files if category_for(path) == "tests"]
generated_at = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()
audit = {
"generated_at": generated_at,
"last_tag": args.last_tag,
"current_version": args.current_version,
"pom_base": args.pom_base,
"changed_file_count": len(files),
"category_counts": {key: len(value) for key, value in sorted(categories.items())},
"selected_diff_chars": len(diff_text),
"selected_diff_truncated": diff_truncated,
"public_api_signal_count": len(api_signals),
"javadoc_signal_count": len(doc_signals),
"test_file_count": len(test_files),
}
sections = [
"# ta4j Release Dossier",
"",
"## Metadata",
"",
f"- generated_at: {generated_at}",
f"- current_version: {args.current_version}",
f"- pom_base: {args.pom_base}",
f"- last_reachable_tag: {args.last_tag}",
f"- changed_file_count: {len(files)}",
"",
"## Changed Files by Category",
"",
]
for category in sorted(categories):
sections.append(f"### {category} ({len(categories[category])})")
sections.extend(f"- `{path}`" for path in categories[category])
sections.append("")
sections.extend(
[
"## Unreleased Changelog Context",
"",
"```markdown",
redact(extract_unreleased_changelog()),
"```",
"",
"## Public API Signals",
"",
]
)
sections.extend(f"- `{line}`" for line in api_signals) if api_signals else sections.append("- (none detected)")
sections.extend(["", "## Javadoc and @since Signals", ""])
sections.extend(f"- `{line}`" for line in doc_signals) if doc_signals else sections.append("- (none detected)")
sections.extend(["", "## Test File Signals", ""])
sections.extend(f"- `{path}`" for path in test_files) if test_files else sections.append("- (none detected)")
sections.extend(
[
"",
"## Selected Diff",
"",
"```diff",
diff_text,
"```",
"",
]
)
write_text(args.output, "\n".join(sections))
write_json(args.audit_output, audit)
print(
"audit:release_dossier "
f"file={args.output} changed_files={len(files)} selected_diff_chars={len(diff_text)} "
f"selected_diff_truncated={'true' if diff_truncated else 'false'}"
)
append_output("dossier_path", str(args.output))
append_output("audit_path", str(args.audit_output))
append_output("changed_file_count", str(len(files)))
append_output("selected_diff_chars", str(len(diff_text)))
append_output("selected_diff_truncated", "true" if diff_truncated else "false")
return 0
def command_build_ai_request(args: argparse.Namespace) -> int:
dossier = args.dossier.read_text(encoding="utf-8")
if len(dossier) > args.max_dossier_chars:
dossier = dossier[: args.max_dossier_chars] + "\n\n[TRUNCATED: dossier exceeded request budget]\n"
if args.semver_rules.exists() and args.semver_rules.stat().st_size > 0:
semver_rules = args.semver_rules.read_text(encoding="utf-8")
rules_source = str(args.semver_rules)
else:
semver_rules = textwrap.dedent(
"""\
Decide release go/no-go from unreleased binary-impacting changes.
If go, choose bump: patch|minor.
MINOR: backward-compatible, user-visible new features, and breaking-change cases.
PATCH: backward-compatible bug fixes or internal improvements.
If binary change count is 0 => should_release=false.
If binary change list empty or changelog-only => should_release=false.
If unsure between MINOR and PATCH, prefer PATCH.
Pre-1.0.0: breaking changes can be MINOR.
"""
).strip()
rules_source = "default"
request = {
"model": args.model,
"temperature": 0,
"messages": [
{
"role": "system",
"content": (
"You are a SemVer release reviewer for a Java library. "
"Return JSON only. Base every conclusion on the release dossier."
),
},
{
"role": "user",
"content": (
"Decide whether ta4j should cut a release from this dossier. "
"If yes, choose bump patch or minor. Major is disabled for this workflow.\n\n"
"SemVer rules:\n"
f"{semver_rules}\n\n"
"Return JSON only with this shape:\n"
"{"
"\"should_release\": true|false, "
"\"bump\": \"patch|minor\", "
"\"confidence\": 0.0-1.0, "
"\"reason\": \"1-2 sentences\", "
"\"evidence\": [\"specific dossier facts\"], "
"\"risks\": [\"release risks or empty array\"], "
"\"missing\": [\"missing changelog/javadoc/test evidence or empty array\"]"
"}.\n\n"
f"{dossier}"
),
},
],
}
write_json(args.output, request)
request_size = args.output.stat().st_size
print(
"audit:ai_request "
f"file={args.output} model={args.model} semver_rules_source={rules_source} request_json_size_bytes={request_size}"
)
append_output("request_json_size_bytes", str(request_size))
append_output("dossier_chars", str(len(dossier)))
return 0
def parse_json_object(raw: str) -> dict[str, Any] | None:
candidate = raw.strip()
if candidate.startswith("```"):
candidate = re.sub(r"^```(?:json)?\s*", "", candidate)
candidate = re.sub(r"\s*```$", "", candidate)
try:
parsed = json.loads(candidate)
return parsed if isinstance(parsed, dict) else None
except json.JSONDecodeError:
pass
start = candidate.find("{")
end = candidate.rfind("}")
if start >= 0 and end > start:
try:
parsed = json.loads(candidate[start : end + 1])
return parsed if isinstance(parsed, dict) else None
except json.JSONDecodeError:
return None
return None
def parse_release_flag(value: Any) -> tuple[bool, str]:
if isinstance(value, bool):
return value, ""
if isinstance(value, (int, float)) and not isinstance(value, bool):
if value == 1:
return True, ""
if value == 0:
return False, ""
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"true", "1", "yes", "y", "on"}:
return True, ""
if normalized in {"false", "0", "no", "n", "off", ""}:
return False, ""
return False, f"invalid should_release '{value}', defaulted to false"
def normalize_decision(parsed: dict[str, Any] | None, raw: str) -> dict[str, Any]:
if parsed is None:
hint = raw[:200].replace("\n", " ").strip()
reason = "AI response was not valid JSON"
if hint:
reason = f"{reason}: {hint}"
return {
"should_release": False,
"bump": "patch",
"confidence": 0.0,
"warning": "Invalid AI JSON",
"reason": reason,
"evidence": [],
"risks": ["AI response could not be parsed"],
"missing": [],
}
should_release, flag_warning = parse_release_flag(parsed.get("should_release", False))
bump = str(parsed.get("bump") or "patch").strip().lower()
warning = str(parsed.get("warning") or "")
if flag_warning:
warning = (warning + "; " if warning else "") + flag_warning
if bump == "major":
bump = "minor"
warning = (warning + "; " if warning else "") + "major bump disabled, downgraded to minor"
if bump not in {"patch", "minor"}:
warning = (warning + "; " if warning else "") + f"invalid bump '{bump}', defaulted to patch"
bump = "patch"
if not should_release:
bump = "patch"
confidence_raw = parsed.get("confidence", 0.0)
try:
confidence = float(confidence_raw)
except (TypeError, ValueError):
confidence = 0.0
confidence = max(0.0, min(1.0, confidence))
def string_list(key: str) -> list[str]:
value = parsed.get(key, [])
if not isinstance(value, list):
return [str(value)]
return [str(item) for item in value]
return {
"should_release": should_release,
"bump": bump,
"confidence": confidence,
"warning": warning,
"reason": str(parsed.get("reason") or ""),
"evidence": string_list("evidence"),
"risks": string_list("risks"),
"missing": string_list("missing"),
}
def command_parse_decision(args: argparse.Namespace) -> int:
raw = args.raw_file.read_text(encoding="utf-8") if args.raw_file.exists() else ""
decision = normalize_decision(parse_json_object(raw), raw)
write_json(args.output, decision)
append_output("should_release", "true" if decision["should_release"] else "false", args.github_output)
append_output("bump", str(decision["bump"]), args.github_output)
append_output("confidence", str(decision["confidence"]), args.github_output)
append_output("warning", str(decision["warning"]), args.github_output)
append_output("reason", str(decision["reason"]), args.github_output)
print(
"audit:ai_decision "
f"should_release={'true' if decision['should_release'] else 'false'} bump={decision['bump']} "
f"confidence={decision['confidence']} output={args.output}"
)
return 0
def normalize_javadoc_warning(line: str) -> str | None:
value = line.strip()
if not value:
return None
value = re.sub(r"^\[[A-Z]+\]\s*", "", value)
if value == "Javadoc Warnings":
return None
lower = value.lower()
is_javadoc_warning = (
("javadoc" in lower and "warning" in lower)
or re.search(r"\bwarning\s*[-:]", lower) is not None
)
if not is_javadoc_warning:
return None
# Compiler deprecation diagnostics are Maven warnings, not Javadoc debt.
if re.search(r"\.java:\[\d+,\d+\]", value):
return None
path_match = JAVADOC_PATH_PATTERN.match(value)
if path_match:
value = path_match.group(1)
value = re.sub(r"(\.java):\d+:\s+warning:", r"\1: warning:", value)
return redact_log_path(value)
def unique_preserving_order(values: list[str]) -> list[str]:
seen: set[str] = set()
unique: list[str] = []
for value in values:
if value not in seen:
seen.add(value)
unique.append(value)
return unique
def collect_javadoc_warnings(logs: list[pathlib.Path]) -> tuple[list[str], list[str]]:
warnings: list[str] = []
missing_logs: list[str] = []
for log_file in logs:
if not log_file.exists():
missing_logs.append(str(log_file))
continue
for line in log_file.read_text(encoding="utf-8", errors="replace").splitlines():
warning = normalize_javadoc_warning(line)
if warning:
warnings.append(warning)
return unique_preserving_order(warnings), missing_logs
def load_javadoc_baseline(path: pathlib.Path) -> list[str]:
if not path.exists():
return []
values: list[str] = []
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if line and not line.startswith("#"):
normalized = normalize_javadoc_warning(line)
values.append(normalized if normalized else line)
return unique_preserving_order(values)
def command_javadoc_warnings(args: argparse.Namespace) -> int:
current, missing_logs = collect_javadoc_warnings(args.logs)
baseline = load_javadoc_baseline(args.baseline)
baseline_set = set(baseline)
current_set = set(current)
new_warnings = [warning for warning in current if warning not in baseline_set]
resolved_warnings = [warning for warning in baseline if warning not in current_set]
lines = [
"# Javadoc Warning Baseline Check",
"",
f"baseline={args.baseline}",
f"current_count={len(current)}",
f"baseline_count={len(baseline)}",
f"new_count={len(new_warnings)}",
f"resolved_count={len(resolved_warnings)}",
"",
"## Current warnings",
*(current if current else ["(none)"]),
"",
"## New warnings",
*(new_warnings if new_warnings else ["(none)"]),
"",
"## Baseline warnings not seen in this run",
*(resolved_warnings if resolved_warnings else ["(none)"]),
"",
"## Missing logs",
*(missing_logs if missing_logs else ["(none)"]),
"",
]
write_text(args.output, "\n".join(lines))
append_output("javadoc_warning_count", str(len(current)), args.github_output)
append_output("javadoc_warning_baseline_count", str(len(baseline)), args.github_output)
append_output("javadoc_warning_new_count", str(len(new_warnings)), args.github_output)
append_output("javadoc_warning_resolved_count", str(len(resolved_warnings)), args.github_output)
print(
"audit:javadoc_warnings "
f"current={len(current)} baseline={len(baseline)} new={len(new_warnings)} "
f"resolved={len(resolved_warnings)} output={args.output}"
)
for missing_log in missing_logs:
print(f"::warning::Javadoc warning log not found: {missing_log}")
if new_warnings and args.fail_on_new:
for warning in new_warnings:
print(f"::error::New Javadoc warning: {warning}", file=sys.stderr)
return 1
return 0
def command_artifact_manifest(args: argparse.Namespace) -> int:
version = args.version.strip()
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
print(f"::error::Release artifact version must be major.minor.patch: {version}", file=sys.stderr)
return 1
expected = [pathlib.Path(template.format(version=version)) for template in EXPECTED_RELEASE_ARTIFACTS]
existing_jars = sorted(pathlib.Path(".").glob("*/target/*.jar"))
expected_set = {path.as_posix() for path in expected}
existing_set = {path.as_posix().removeprefix("./") for path in existing_jars}
missing = sorted(path.as_posix() for path in expected if not path.exists())
unexpected = sorted(path for path in existing_set if path not in expected_set)
manifest_lines = [
f"version={version}",
"",
"Expected release artifacts:",
*[f"- {path.as_posix()}" for path in expected],
"",
"Missing release artifacts:",
*([f"- {path}" for path in missing] if missing else ["- (none)"]),
"",
"Unexpected target jars:",
*([f"- {path}" for path in unexpected] if unexpected else ["- (none)"]),
"",
]
write_text(args.output, "\n".join(manifest_lines))
files_output = "\n".join(path.as_posix() for path in expected)
append_output("files", files_output, args.github_output)
append_output("artifact_manifest", str(args.output), args.github_output)
append_output("missing_count", str(len(missing)), args.github_output)
append_output("unexpected_count", str(len(unexpected)), args.github_output)
print(
"audit:artifact_manifest "
f"version={version} expected={len(expected)} missing={len(missing)} unexpected={len(unexpected)} output={args.output}"
)
if missing or (unexpected and args.strict):
if missing:
print(f"::error::Missing release artifacts: {', '.join(missing)}", file=sys.stderr)
if unexpected and args.strict:
print(f"::error::Unexpected target jars: {', '.join(unexpected)}", file=sys.stderr)
return 1
return 0
def snapshot_publication_result(
*,
version: str,
published: str,
latest: str,
last_updated: str,
source: str,
error: str = "",
versions: list[str] | None = None,
) -> dict[str, Any]:
"""Return a stable snapshot-publication payload for workflow consumers."""
return {
"version": version,
"published": published,
"latest": latest,
"lastUpdated": last_updated,
"source": source,
"error": error,
"versions": versions or [],
}
def emit_snapshot_publication_outputs(result: dict[str, Any], github_output: str | None) -> None:
"""Expose snapshot-publication fields through the GitHub Actions output contract."""
append_output("snapshot_publication", str(result["published"]), github_output)
append_output("snapshot_publication_latest", str(result["latest"]), github_output)
append_output("snapshot_publication_last_updated", str(result["lastUpdated"]), github_output)
append_output("snapshot_publication_source", str(result["source"]), github_output)
append_output("snapshot_publication_error", str(result["error"]), github_output)
def snapshot_publication_policy_result(
*,
event_name: str,
workflow_name: str,
enforce: bool,
pending_reason: str,
) -> dict[str, Any]:
"""Describe when release-health should treat snapshot metadata as authoritative."""
return {
"eventName": event_name,
"workflowName": workflow_name,
"enforce": enforce,
"pendingReason": pending_reason,
}
def emit_snapshot_publication_policy_outputs(result: dict[str, Any], github_output: str | None) -> None:
"""Expose snapshot-publication policy fields through the GitHub Actions output contract."""
append_output("snapshot_publication_enforced", "true" if result["enforce"] else "false", github_output)
append_output("snapshot_publication_pending_reason", str(result["pendingReason"]), github_output)
def validate_snapshot_metadata_url(metadata_url: str) -> str:
"""Reject non-HTTPS metadata URLs; local fixtures must use --metadata-file instead."""
parsed = urlparse(metadata_url.strip())
if parsed.scheme.lower() != "https" or not parsed.netloc:
raise ValueError("--metadata-url must use https")
return metadata_url
def load_snapshot_metadata(args: argparse.Namespace) -> tuple[str, str]:
"""Load snapshot metadata XML from a local fixture or the live snapshot repository."""
if args.metadata_file:
source = args.metadata_file.resolve().as_uri()
return args.metadata_file.read_text(encoding="utf-8"), source
metadata_url = validate_snapshot_metadata_url(args.metadata_url)
request = urllib.request.Request(
metadata_url,
headers={
"Accept": "application/xml",
"User-Agent": "ta4j-release-automation",
},
)
with urllib.request.urlopen(request, timeout=args.timeout_seconds) as response:
return response.read().decode("utf-8"), metadata_url
def parse_snapshot_metadata(metadata_text: str) -> tuple[str, str, list[str]]:
"""Extract latest version, timestamp, and published versions from metadata XML."""
root = ET.fromstring(metadata_text)
versioning = root.find("versioning")
if versioning is None:
raise ValueError("snapshot metadata is missing the <versioning> section")
latest = (versioning.findtext("latest") or "").strip()
last_updated = (versioning.findtext("lastUpdated") or "").strip()
versions_parent = versioning.find("versions")
versions = []
if versions_parent is not None:
versions = [
(node.text or "").strip()
for node in versions_parent.findall("version")
if (node.text or "").strip()
]
return latest, last_updated, versions
def command_snapshot_publication(args: argparse.Namespace) -> int:
"""Resolve whether the requested ta4j snapshot version is currently published."""
version = args.version.strip()
if not version:
print("::error::--version is required", file=sys.stderr)
return 1
if not version.endswith("-SNAPSHOT"):
result = snapshot_publication_result(
version=version,
published="n/a",
latest="",
last_updated="",
source=args.metadata_file.resolve().as_uri() if args.metadata_file else args.metadata_url,
)
write_json(args.output, result)
emit_snapshot_publication_outputs(result, args.github_output)
print(f"audit:snapshot_publication version={version} published=n/a output={args.output}")
return 0
try:
metadata_text, source = load_snapshot_metadata(args)
latest, last_updated, versions = parse_snapshot_metadata(metadata_text)
except (ET.ParseError, OSError, UnicodeDecodeError, ValueError) as exc:
result = snapshot_publication_result(
version=version,
published="unknown",
latest="",
last_updated="",
source=args.metadata_file.resolve().as_uri() if args.metadata_file else args.metadata_url,
error=str(exc),
)
write_json(args.output, result)
emit_snapshot_publication_outputs(result, args.github_output)
print(f"::warning::Unable to verify snapshot publication for {version}: {exc}")
print(f"audit:snapshot_publication version={version} published=unknown output={args.output}")
return 0
published = "true" if version in set(versions) else "false"
result = snapshot_publication_result(
version=version,
published=published,
latest=latest,
last_updated=last_updated,
source=source,
versions=versions,
)
write_json(args.output, result)
emit_snapshot_publication_outputs(result, args.github_output)
print(
"audit:snapshot_publication "
f"version={version} published={published} latest={latest or 'unknown'} "
f"last_updated={last_updated or 'unknown'} output={args.output}"
)
return 0
def command_snapshot_publication_policy(args: argparse.Namespace) -> int:
"""Decide whether snapshot-publication failures should count as release-health drift."""
event_name = args.event_name.strip()
workflow_name = args.workflow_name.strip()
enforce = event_name in {"schedule", "workflow_dispatch"} or (
event_name == "workflow_run" and workflow_name == SNAPSHOT_WORKFLOW_NAME
)
pending_reason = (
""
if enforce
else "awaiting snapshot workflow completion before treating snapshot metadata drift as authoritative"
)
result = snapshot_publication_policy_result(
event_name=event_name,
workflow_name=workflow_name,
enforce=enforce,
pending_reason=pending_reason,
)
write_json(args.output, result)
emit_snapshot_publication_policy_outputs(result, args.github_output)
print(
"audit:snapshot_publication_policy "
f"event={event_name or 'unknown'} workflow={workflow_name or 'none'} "
f"enforced={'true' if enforce else 'false'} output={args.output}"
)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
catalog = subparsers.add_parser("catalog-preflight")
catalog.add_argument("--model", required=True)
catalog.add_argument("--catalog-url", default="https://models.github.ai/catalog/models")
catalog.add_argument("--catalog-file")
catalog.add_argument("--timeout-seconds", type=int, default=30)
catalog.add_argument("--output", type=pathlib.Path, default=pathlib.Path("release-ai-model.json"))
catalog.set_defaults(func=command_catalog_preflight)
dossier = subparsers.add_parser("build-dossier")
dossier.add_argument("--last-tag", required=True)
dossier.add_argument("--current-version", required=True)
dossier.add_argument("--pom-base", required=True)
dossier.add_argument("--max-diff-chars", type=int, default=600_000)
dossier.add_argument("--output", type=pathlib.Path, default=pathlib.Path("release-dossier.md"))
dossier.add_argument("--audit-output", type=pathlib.Path, default=pathlib.Path("release-audit.json"))
dossier.set_defaults(func=command_build_dossier)
request = subparsers.add_parser("build-ai-request")
request.add_argument("--model", required=True)
request.add_argument("--dossier", type=pathlib.Path, default=pathlib.Path("release-dossier.md"))
request.add_argument("--semver-rules", type=pathlib.Path, default=pathlib.Path(".github/workflows/semver-rules-override.txt"))
request.add_argument("--max-dossier-chars", type=int, default=900_000)
request.add_argument("--output", type=pathlib.Path, default=pathlib.Path("request.json"))
request.set_defaults(func=command_build_ai_request)
parse = subparsers.add_parser("parse-decision")
parse.add_argument("--raw-file", type=pathlib.Path, default=pathlib.Path("ai-content.txt"))
parse.add_argument("--output", type=pathlib.Path, default=pathlib.Path("release-decision.json"))
parse.add_argument("--github-output")
parse.set_defaults(func=command_parse_decision)
javadoc = subparsers.add_parser("javadoc-warnings")
javadoc.add_argument("--baseline", type=pathlib.Path, default=pathlib.Path("scripts/release/javadoc-warning-baseline.txt"))
javadoc.add_argument("--output", type=pathlib.Path, default=pathlib.Path("javadoc-warnings.txt"))
javadoc.add_argument("--github-output")
javadoc.add_argument("--fail-on-new", action="store_true")
javadoc.add_argument("logs", type=pathlib.Path, nargs="*")
javadoc.set_defaults(func=command_javadoc_warnings)
manifest = subparsers.add_parser("artifact-manifest")
manifest.add_argument("--version", required=True)
manifest.add_argument("--output", type=pathlib.Path, default=pathlib.Path("artifact-manifest.txt"))
manifest.add_argument("--github-output")
manifest.add_argument("--strict", action="store_true")
manifest.set_defaults(func=command_artifact_manifest)
snapshot = subparsers.add_parser("snapshot-publication")
snapshot.add_argument("--version", required=True)
snapshot.add_argument("--metadata-url", default=SNAPSHOT_METADATA_URL)
snapshot.add_argument("--metadata-file", type=pathlib.Path)
snapshot.add_argument("--timeout-seconds", type=int, default=30)
snapshot.add_argument("--output", type=pathlib.Path, default=pathlib.Path("snapshot-publication.json"))
snapshot.add_argument("--github-output")
snapshot.set_defaults(func=command_snapshot_publication)
snapshot_policy = subparsers.add_parser("snapshot-publication-policy")
snapshot_policy.add_argument("--event-name", required=True)
snapshot_policy.add_argument("--workflow-name", default="")
snapshot_policy.add_argument("--output", type=pathlib.Path, default=pathlib.Path("snapshot-publication-policy.json"))
snapshot_policy.add_argument("--github-output")
snapshot_policy.set_defaults(func=command_snapshot_publication_policy)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
return int(args.func(args))
except Exception as exc:
print(f"::error::{exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())