goldenChat base source add
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# AGENTS instructions for `ta4j/scripts`
|
||||
|
||||
Use this guide when editing helper scripts or when orchestrating worktree/process commands from this directory.
|
||||
|
||||
## Build script contract
|
||||
|
||||
- `run-full-build-quiet.sh` must enforce a default 3-minute timeout (`QUIET_BUILD_TIMEOUT_SECONDS` can override).
|
||||
- Preserve quiet-build behavior: full log under `.agents/logs/` and concise aggregated stdout.
|
||||
- Use `mktemp` templates ending in `XXXXXX` for macOS portability.
|
||||
|
||||
## Scoped AGENTS discovery helper
|
||||
|
||||
- `agents_for_target.sh` should work from repo root or workspace root.
|
||||
- Accept either a class/file name or a path, and print path-scoped `AGENTS.md` files in precedence order for matching targets from the current repo/workspace root.
|
||||
- Keep the helper limited to path-scoped `AGENTS.md` discovery; agents must still load system, developer, user, and workflow-specific instructions separately.
|
||||
|
||||
## Worktree/process operations
|
||||
|
||||
- For non-trivial work, prefer dedicated worktrees under `.agents/worktrees/`.
|
||||
- Keep a living PRD/checklist during implementation so work can resume with minimal rediscovery.
|
||||
- Use branch prefixes `feature/`, `bugfix/`, or `refactor/`.
|
||||
|
||||
## Release helper scripts
|
||||
|
||||
- Keep release workflow business logic in reusable scripts under `scripts/release/` instead of copying large logic blocks into workflow YAML.
|
||||
- When adding release helper behavior, add fixture coverage under `scripts/tests/` and keep GitHub Actions outputs/artifacts sanitized.
|
||||
- Release helper scripts should emit concise `audit:` lines for workflow logs and write structured files that can be uploaded with `actions/upload-artifact`.
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
target_glob="${1:-}"
|
||||
if [ -z "$target_glob" ]; then
|
||||
echo "Usage: $(basename "$0") <target_filename_or_glob>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
workspace_root() {
|
||||
# Prefer git root if available
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -n "$root" ]; then
|
||||
echo "$root"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Otherwise, walk up looking for common project sentinels
|
||||
d="$PWD"
|
||||
while [ "$d" != "/" ]; do
|
||||
if [ -d "$d/.git" ] || \
|
||||
[ -f "$d/pyproject.toml" ] || \
|
||||
[ -f "$d/package.json" ] || \
|
||||
[ -f "$d/pom.xml" ] || \
|
||||
[ -f "$d/build.gradle" ] || \
|
||||
[ -f "$d/go.mod" ] || \
|
||||
[ -f "$d/Cargo.toml" ]; then
|
||||
echo "$d"
|
||||
return
|
||||
fi
|
||||
d="$(dirname "$d")"
|
||||
done
|
||||
|
||||
# Fallback: current directory
|
||||
echo "$PWD"
|
||||
}
|
||||
|
||||
root="$(workspace_root)"
|
||||
|
||||
# If rg isn't available, fail fast with a clear message
|
||||
if ! command -v rg >/dev/null 2>&1; then
|
||||
echo "Error: ripgrep (rg) not found in PATH." >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
# Find matches under root, including hidden and ignored files,
|
||||
# so AGENTS candidates are discovered even in filtered directories.
|
||||
rg --files --no-ignore --hidden -g "$target_glob" "$root" 2>/dev/null \
|
||||
| while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
dir="$(dirname "$file")"
|
||||
|
||||
# Walk upward from the target's directory to the workspace root
|
||||
while :; do
|
||||
agents="$dir/AGENTS.md"
|
||||
if [ -f "$agents" ]; then
|
||||
echo "$agents"
|
||||
fi
|
||||
|
||||
[ "$dir" = "$root" ] && break
|
||||
parent="$(dirname "$dir")"
|
||||
[ "$parent" = "$dir" ] && break
|
||||
dir="$parent"
|
||||
done
|
||||
done \
|
||||
| awk '!seen[$0]++'
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
scripts/benchmark-backtest-throughput.sh [base-ref] [candidate-ref] [output-dir] [-- harness args...]
|
||||
|
||||
Defaults:
|
||||
base-ref: HEAD^
|
||||
candidate-ref: HEAD
|
||||
output-dir: .agents/benchmarks/backtest-throughput/<timestamp>
|
||||
|
||||
The script runs BacktestPerformanceTuningHarness throughput-control mode in
|
||||
temporary worktrees for both refs, then compares matrix_performance.json
|
||||
cells/min and hypotheses/min.
|
||||
|
||||
Both refs must already contain throughput-control support. The intended
|
||||
candidate-flow comparison is HEAD^ vs HEAD after adding the harness, telemetry,
|
||||
and optimization commits.
|
||||
USAGE
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
base_ref="HEAD^"
|
||||
candidate_ref="HEAD"
|
||||
output_dir="$repo_root/.agents/benchmarks/backtest-throughput/$timestamp"
|
||||
if [[ "${1:-}" != "" && "${1:-}" != "--" ]]; then
|
||||
base_ref="$1"
|
||||
shift
|
||||
fi
|
||||
if [[ "${1:-}" != "" && "${1:-}" != "--" ]]; then
|
||||
candidate_ref="$1"
|
||||
shift
|
||||
fi
|
||||
if [[ "${1:-}" != "" && "${1:-}" != "--" ]]; then
|
||||
output_dir="$1"
|
||||
shift
|
||||
fi
|
||||
if [[ "$output_dir" != /* ]]; then
|
||||
output_dir="$repo_root/$output_dir"
|
||||
fi
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
|
||||
default_harness_args=(
|
||||
--throughputControl
|
||||
--matrixStrategyCounts "250,500,1000"
|
||||
--matrixBarCounts "500,1000"
|
||||
--matrixMaxBarCountHints 0
|
||||
--executionMode topK
|
||||
--topK 10
|
||||
--parallelism 1
|
||||
)
|
||||
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
harness_args=(--throughputControl "$@")
|
||||
else
|
||||
harness_args=("${default_harness_args[@]}")
|
||||
fi
|
||||
|
||||
worktree_parent="$output_dir/worktrees"
|
||||
mkdir -p "$output_dir" "$worktree_parent"
|
||||
worktree_root="$(mktemp -d "$worktree_parent/run-XXXXXX")"
|
||||
base_worktree="$worktree_root/base"
|
||||
candidate_worktree="$worktree_root/candidate"
|
||||
base_output="$output_dir/base"
|
||||
candidate_output="$output_dir/candidate"
|
||||
comparison_json="$output_dir/comparison.json"
|
||||
comparison_md="$output_dir/summary.md"
|
||||
|
||||
cleanup() {
|
||||
git -C "$repo_root" worktree remove --force "$base_worktree" >/dev/null 2>&1 || true
|
||||
git -C "$repo_root" worktree remove --force "$candidate_worktree" >/dev/null 2>&1 || true
|
||||
rm -rf "$worktree_root"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
git -C "$repo_root" worktree add --detach "$base_worktree" "$base_ref" >/dev/null
|
||||
git -C "$repo_root" worktree add --detach "$candidate_worktree" "$candidate_ref" >/dev/null
|
||||
|
||||
quote_exec_arg() {
|
||||
local value="$1"
|
||||
if [[ "$value" =~ ^[A-Za-z0-9_./:=,+%-]+$ ]]; then
|
||||
printf '%s' "$value"
|
||||
return
|
||||
fi
|
||||
value="${value//\\/\\\\}"
|
||||
value="${value//\"/\\\"}"
|
||||
printf '"%s"' "$value"
|
||||
}
|
||||
|
||||
run_ref() {
|
||||
local worktree="$1"
|
||||
local run_output="$2"
|
||||
mkdir -p "$run_output"
|
||||
local exec_args=""
|
||||
local arg
|
||||
for arg in "${harness_args[@]}" --throughputOutputDir "$run_output"; do
|
||||
local quoted_arg
|
||||
quoted_arg="$(quote_exec_arg "$arg")"
|
||||
if [[ -n "$exec_args" ]]; then
|
||||
exec_args+=" "
|
||||
fi
|
||||
exec_args+="$quoted_arg"
|
||||
done
|
||||
(
|
||||
cd "$worktree"
|
||||
mvn -q -pl ta4j-examples -am compile
|
||||
mvn -q -pl ta4j-examples exec:java \
|
||||
-Dexec.mainClass=ta4jexamples.backtesting.BacktestPerformanceTuningHarness \
|
||||
-Dexec.args="$exec_args"
|
||||
)
|
||||
}
|
||||
|
||||
run_ref "$base_worktree" "$base_output"
|
||||
run_ref "$candidate_worktree" "$candidate_output"
|
||||
|
||||
python3 - "$base_output/matrix_performance.json" "$candidate_output/matrix_performance.json" \
|
||||
"$comparison_json" "$comparison_md" "$base_ref" "$candidate_ref" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
base_path = Path(sys.argv[1])
|
||||
candidate_path = Path(sys.argv[2])
|
||||
comparison_path = Path(sys.argv[3])
|
||||
summary_path = Path(sys.argv[4])
|
||||
base_ref = sys.argv[5]
|
||||
candidate_ref = sys.argv[6]
|
||||
|
||||
base = json.loads(base_path.read_text())
|
||||
candidate = json.loads(candidate_path.read_text())
|
||||
|
||||
required_metrics = (
|
||||
"cellsPerMinute",
|
||||
"hypothesesPerMinute",
|
||||
"totalWallTimeMs",
|
||||
"strategyBuildWallTimeMs",
|
||||
"backtestRuntimeMs",
|
||||
)
|
||||
required_meta = (
|
||||
"specFingerprint",
|
||||
"dataset",
|
||||
"cellCount",
|
||||
"hypothesisCount",
|
||||
"parallelism",
|
||||
"resolvedParallelism",
|
||||
"executionMode",
|
||||
"topK",
|
||||
"progress",
|
||||
"gcBetweenRuns",
|
||||
)
|
||||
|
||||
for key in (*required_metrics, *required_meta):
|
||||
if key not in base or key not in candidate:
|
||||
raise SystemExit(f"Missing required key '{key}' in matrix_performance.json")
|
||||
if "host" not in base or "host" not in candidate:
|
||||
raise SystemExit("Missing required key 'host' in matrix_performance.json")
|
||||
if "hostId" not in base["host"] or "hostId" not in candidate["host"]:
|
||||
raise SystemExit("Missing required key 'host.hostId' in matrix_performance.json")
|
||||
|
||||
def pct(before, after):
|
||||
if before == 0:
|
||||
return None
|
||||
return (after - before) * 100.0 / before
|
||||
|
||||
metrics = {}
|
||||
for name in required_metrics:
|
||||
before = float(base[name])
|
||||
after = float(candidate[name])
|
||||
metrics[name] = {
|
||||
"base": before,
|
||||
"candidate": after,
|
||||
"delta": after - before,
|
||||
"deltaPct": pct(before, after),
|
||||
}
|
||||
|
||||
comparison = {
|
||||
"baseRef": base_ref,
|
||||
"candidateRef": candidate_ref,
|
||||
"basePath": str(base_path),
|
||||
"candidatePath": str(candidate_path),
|
||||
"specFingerprintMatch": base.get("specFingerprint") == candidate.get("specFingerprint"),
|
||||
"datasetMatch": base.get("dataset") == candidate.get("dataset"),
|
||||
"cellCountMatch": base.get("cellCount") == candidate.get("cellCount"),
|
||||
"hypothesisCountMatch": base.get("hypothesisCount") == candidate.get("hypothesisCount"),
|
||||
"hostIdMatch": base["host"]["hostId"] == candidate["host"]["hostId"],
|
||||
"metrics": metrics,
|
||||
}
|
||||
comparison_path.write_text(json.dumps(comparison, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
if not all((
|
||||
comparison["specFingerprintMatch"],
|
||||
comparison["datasetMatch"],
|
||||
comparison["cellCountMatch"],
|
||||
comparison["hypothesisCountMatch"],
|
||||
comparison["hostIdMatch"],
|
||||
)):
|
||||
raise SystemExit("Invalid comparison: host/dataset/spec/hypothesis/cell counts differ between refs.")
|
||||
|
||||
def fmt(value):
|
||||
return "n/a" if value is None else f"{value:.2f}"
|
||||
|
||||
lines = [
|
||||
f"# Backtest Throughput Comparison",
|
||||
"",
|
||||
f"- Base: `{base_ref}`",
|
||||
f"- Candidate: `{candidate_ref}`",
|
||||
f"- Dataset match: `{comparison['datasetMatch']}`",
|
||||
f"- Spec fingerprint match: `{comparison['specFingerprintMatch']}`",
|
||||
f"- Cell count match: `{comparison['cellCountMatch']}`",
|
||||
f"- Hypothesis count match: `{comparison['hypothesisCountMatch']}`",
|
||||
f"- Host ID match: `{comparison['hostIdMatch']}`",
|
||||
"",
|
||||
"| Metric | Base | Candidate | Delta % |",
|
||||
"| --- | ---: | ---: | ---: |",
|
||||
]
|
||||
for name in ("cellsPerMinute", "hypothesesPerMinute", "totalWallTimeMs", "strategyBuildWallTimeMs", "backtestRuntimeMs"):
|
||||
metric = metrics[name]
|
||||
lines.append(f"| {name} | {metric['base']:.2f} | {metric['candidate']:.2f} | {fmt(metric['deltaPct'])} |")
|
||||
summary_path.write_text("\n".join(lines) + "\n")
|
||||
print(f"comparison: {comparison_path}")
|
||||
print(f"summary: {summary_path}")
|
||||
PY
|
||||
|
||||
cat "$comparison_md"
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
export ROOT_DIR
|
||||
|
||||
python3 <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(os.environ["ROOT_DIR"])
|
||||
wiki_root = root.parent / "ta4j-wiki"
|
||||
wiki_available = wiki_root.exists()
|
||||
|
||||
docs_for_link_checks = [root / "README.md", root / "ta4j-examples" / "README.md"]
|
||||
if wiki_available:
|
||||
docs_for_link_checks.extend(sorted(wiki_root.glob("*.md")))
|
||||
|
||||
docs_for_command_checks = [
|
||||
root / "README.md",
|
||||
root / "ta4j-examples" / "README.md",
|
||||
]
|
||||
if wiki_available:
|
||||
docs_for_command_checks.extend(
|
||||
[
|
||||
wiki_root / "Usage-examples.md",
|
||||
wiki_root / "Examples-Expected-Outputs.md",
|
||||
wiki_root / "Performance-Characterization.md",
|
||||
]
|
||||
)
|
||||
|
||||
todo_forbidden_docs = [
|
||||
root / "README.md",
|
||||
root / "ta4j-examples" / "README.md",
|
||||
]
|
||||
if wiki_available:
|
||||
todo_forbidden_docs.extend(
|
||||
[
|
||||
wiki_root / "Home.md",
|
||||
wiki_root / "Getting-started.md",
|
||||
wiki_root / "Backtesting.md",
|
||||
wiki_root / "Live-trading.md",
|
||||
wiki_root / "Canonical-User-Journey.md",
|
||||
wiki_root / "Execution-Decision-Matrix.md",
|
||||
wiki_root / "Migration-and-Version-Compatibility.md",
|
||||
wiki_root / "Examples-Expected-Outputs.md",
|
||||
wiki_root / "Performance-Characterization.md",
|
||||
]
|
||||
)
|
||||
|
||||
required_canonical_docs = [
|
||||
root / "README.md",
|
||||
root / "ta4j-examples" / "README.md",
|
||||
root / "ta4j-core" / "README.md",
|
||||
]
|
||||
if wiki_available:
|
||||
required_canonical_docs.extend(
|
||||
[
|
||||
wiki_root / "Home.md",
|
||||
wiki_root / "Getting-started.md",
|
||||
wiki_root / "Canonical-User-Journey.md",
|
||||
wiki_root / "Backtesting-Realism-Checklist.md",
|
||||
wiki_root / "Live-Trading-Runbook.md",
|
||||
wiki_root / "Troubleshooting-Hub.md",
|
||||
wiki_root / "Execution-Decision-Matrix.md",
|
||||
wiki_root / "Migration-and-Version-Compatibility.md",
|
||||
wiki_root / "Examples-Expected-Outputs.md",
|
||||
wiki_root / "Performance-Characterization.md",
|
||||
]
|
||||
)
|
||||
|
||||
link_pattern = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
|
||||
main_class_pattern = re.compile(r"-Dexec\.mainClass=([A-Za-z0-9_.]+)")
|
||||
|
||||
errors = []
|
||||
|
||||
|
||||
def normalize_target(raw_target: str) -> str:
|
||||
target = raw_target.strip()
|
||||
if " " in target and target.startswith(("http://", "https://")):
|
||||
target = target.split(" ", 1)[0]
|
||||
if "#" in target:
|
||||
target = target.split("#", 1)[0]
|
||||
if "?" in target:
|
||||
target = target.split("?", 1)[0]
|
||||
return target.strip()
|
||||
|
||||
|
||||
def check_markdown_links(path: Path) -> None:
|
||||
if not path.exists():
|
||||
errors.append(f"Missing documentation file for link check: {path}")
|
||||
return
|
||||
content = path.read_text(encoding="utf-8")
|
||||
for match in link_pattern.finditer(content):
|
||||
raw_target = match.group(1).strip()
|
||||
target = normalize_target(raw_target)
|
||||
if not target:
|
||||
continue
|
||||
if target.startswith(("http://", "https://", "mailto:")):
|
||||
continue
|
||||
if target.startswith("#"):
|
||||
continue
|
||||
candidate = (path.parent / target).resolve()
|
||||
if not candidate.exists():
|
||||
errors.append(f"{path}: broken relative link target '{raw_target}'")
|
||||
|
||||
|
||||
def check_exec_main_classes(path: Path) -> None:
|
||||
if not path.exists():
|
||||
errors.append(f"Missing documentation file for command check: {path}")
|
||||
return
|
||||
content = path.read_text(encoding="utf-8")
|
||||
for main_class in main_class_pattern.findall(content):
|
||||
class_file = (
|
||||
root
|
||||
/ "ta4j-examples"
|
||||
/ "src"
|
||||
/ "main"
|
||||
/ "java"
|
||||
/ Path(*main_class.split(".")).with_suffix(".java")
|
||||
)
|
||||
if not class_file.exists():
|
||||
errors.append(
|
||||
f"{path}: -Dexec.mainClass points to missing class '{main_class}' ({class_file})"
|
||||
)
|
||||
|
||||
|
||||
def check_for_todo_markers(path: Path) -> None:
|
||||
if not path.exists():
|
||||
errors.append(f"Missing documentation file for TODO check: {path}")
|
||||
return
|
||||
content = path.read_text(encoding="utf-8")
|
||||
if "TODO" in content:
|
||||
errors.append(f"{path}: contains TODO marker in user-facing docs")
|
||||
|
||||
|
||||
for doc_path in docs_for_link_checks:
|
||||
check_markdown_links(doc_path)
|
||||
|
||||
for doc_path in docs_for_command_checks:
|
||||
check_exec_main_classes(doc_path)
|
||||
|
||||
for doc_path in todo_forbidden_docs:
|
||||
check_for_todo_markers(doc_path)
|
||||
|
||||
for doc_path in required_canonical_docs:
|
||||
if not doc_path.exists():
|
||||
errors.append(f"Missing canonical documentation artifact: {doc_path}")
|
||||
|
||||
if errors:
|
||||
print("docs-integrity:fail")
|
||||
for error in errors:
|
||||
print(f"- {error}")
|
||||
sys.exit(1)
|
||||
|
||||
if not wiki_available:
|
||||
print("docs-integrity:note ta4j-wiki checkout not found; skipping wiki checks")
|
||||
print("docs-integrity:pass")
|
||||
PY
|
||||
Executable
+226
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# =============================================================================
|
||||
# prepare-release.sh
|
||||
#
|
||||
# Responsibilities:
|
||||
# - Validate tools
|
||||
# - Update CHANGELOG.md (move "Unreleased" -> new release section)
|
||||
# - Update README.md version references
|
||||
# - Generate standalone release notes file
|
||||
#
|
||||
# What it does NOT do:
|
||||
# - No Maven calls
|
||||
# - No version bumping
|
||||
# - No tagging
|
||||
# - No Git commits
|
||||
#
|
||||
# Usage:
|
||||
# scripts/prepare-release.sh <release-version>
|
||||
#
|
||||
# Output:
|
||||
# release_version=<version>
|
||||
# release_notes_file=<file>
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "Usage: $0 <release-version>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_VERSION="$1"
|
||||
RELEASE_NOTES_FILE="release/${RELEASE_VERSION}.md"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tools check
|
||||
# -----------------------------------------------------------------------------
|
||||
for tool in git perl python3; do
|
||||
command -v "$tool" >/dev/null 2>&1 || {
|
||||
echo "Error: required tool '$tool' not found" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Update CHANGELOG.md
|
||||
# -----------------------------------------------------------------------------
|
||||
update_changelog() {
|
||||
local version="$1"
|
||||
local outfile="$2"
|
||||
|
||||
python3 - "$version" "$outfile" <<'PY'
|
||||
import datetime, sys, re
|
||||
from pathlib import Path
|
||||
|
||||
version = sys.argv[1]
|
||||
outfile = Path(sys.argv[2])
|
||||
|
||||
changelog = Path("CHANGELOG.md")
|
||||
text = changelog.read_text()
|
||||
|
||||
today = datetime.date.today().strftime("%Y-%m-%d")
|
||||
release_header = f"## {version} ({today})"
|
||||
|
||||
# Find the "Unreleased" section
|
||||
m = list(re.finditer(r"^##\s*\[?Unreleased\]?", text, re.IGNORECASE | re.MULTILINE))
|
||||
if not m:
|
||||
# No Unreleased section → create an empty one
|
||||
sections = f"## Unreleased\n\n- _No changes yet._\n\n{release_header}\n\n_No changes recorded._\n"
|
||||
changelog.write_text(sections)
|
||||
outfile.parent.mkdir(exist_ok=True, parents=True)
|
||||
outfile.write_text(f"{release_header}\n\n_No changes recorded._\n")
|
||||
sys.exit(0)
|
||||
|
||||
start = m[0].end()
|
||||
# Find next section after Unreleased
|
||||
next_sec = list(re.finditer(r"^##\s+", text[start:], re.MULTILINE))
|
||||
end = start + (next_sec[0].start() if next_sec else len(text[start:]))
|
||||
|
||||
unreleased_block = text[start:end].strip()
|
||||
if not unreleased_block:
|
||||
unreleased_block = "_No notable changes recorded._"
|
||||
|
||||
# New changelog structure:
|
||||
# 1. Unreleased (reset)
|
||||
# 2. New release version section
|
||||
# 3. Everything after the old Unreleased section
|
||||
|
||||
new_unreleased = "## Unreleased\n\n- _No changes yet._\n\n"
|
||||
new_release = f"{release_header}\n\n{unreleased_block}\n\n"
|
||||
|
||||
new_text = new_unreleased + new_release + text[end:].lstrip()
|
||||
changelog.write_text(new_text)
|
||||
|
||||
# Write standalone release notes
|
||||
outfile.parent.mkdir(exist_ok=True, parents=True)
|
||||
outfile.write_text(f"{release_header}\n\n{unreleased_block}\n")
|
||||
|
||||
PY
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# README Sentinel Validation
|
||||
# -----------------------------------------------------------------------------
|
||||
require_readme_sentinels() {
|
||||
if [[ ! -f README.md ]]; then
|
||||
echo "Error: README.md not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local missing=0
|
||||
local markers=(
|
||||
"TA4J_VERSION_BLOCK:core:stable:begin"
|
||||
"TA4J_VERSION_BLOCK:core:stable:end"
|
||||
"TA4J_VERSION_BLOCK:core:snapshot:begin"
|
||||
"TA4J_VERSION_BLOCK:core:snapshot:end"
|
||||
"TA4J_VERSION_BLOCK:examples:stable:begin"
|
||||
"TA4J_VERSION_BLOCK:examples:stable:end"
|
||||
"TA4J_VERSION_BLOCK:examples:snapshot:begin"
|
||||
"TA4J_VERSION_BLOCK:examples:snapshot:end"
|
||||
)
|
||||
|
||||
for marker in "${markers[@]}"; do
|
||||
if ! grep -Eq "<!--[[:space:]]*${marker}[[:space:]]*-->" README.md; then
|
||||
echo "Error: missing README sentinel <!-- ${marker} -->" >&2
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $missing -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Snapshot Version Calculator
|
||||
# -----------------------------------------------------------------------------
|
||||
compute_snapshot_version() {
|
||||
local version="$1"
|
||||
python3 - "$version" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
version = sys.argv[1]
|
||||
m = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version)
|
||||
if not m:
|
||||
print(f"Error: invalid release version '{version}' (expected major.minor.patch)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
major, minor, patch = map(int, m.groups())
|
||||
print(f"{major}.{minor}.{patch + 1}-SNAPSHOT")
|
||||
PY
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# README / Version Reference Updater
|
||||
# -----------------------------------------------------------------------------
|
||||
update_readme() {
|
||||
local version="$1"
|
||||
local snapshot_version="$2"
|
||||
|
||||
export VERSION="$version"
|
||||
export SNAPSHOT_VERSION="$snapshot_version"
|
||||
|
||||
perl -0777 -i -pe '
|
||||
sub bump_version_tags {
|
||||
my ($block, $v) = @_;
|
||||
$block =~ s{(<version>)[^<]+(</version>)}{$1$v$2}g;
|
||||
return $block;
|
||||
}
|
||||
|
||||
# core stable
|
||||
s{
|
||||
(<!--\s*TA4J_VERSION_BLOCK:core:stable:begin\s*-->)
|
||||
(.*?)
|
||||
(<!--\s*TA4J_VERSION_BLOCK:core:stable:end\s*-->)
|
||||
}{$1 . bump_version_tags($2, $ENV{VERSION}) . $3}gsex;
|
||||
|
||||
# core snapshot
|
||||
s{
|
||||
(<!--\s*TA4J_VERSION_BLOCK:core:snapshot:begin\s*-->)
|
||||
(.*?)
|
||||
(<!--\s*TA4J_VERSION_BLOCK:core:snapshot:end\s*-->)
|
||||
}{$1 . bump_version_tags($2, $ENV{SNAPSHOT_VERSION}) . $3}gsex;
|
||||
|
||||
# examples stable
|
||||
s{
|
||||
(<!--\s*TA4J_VERSION_BLOCK:examples:stable:begin\s*-->)
|
||||
(.*?)
|
||||
(<!--\s*TA4J_VERSION_BLOCK:examples:stable:end\s*-->)
|
||||
}{$1 . bump_version_tags($2, $ENV{VERSION}) . $3}gsex;
|
||||
|
||||
# examples snapshot
|
||||
s{
|
||||
(<!--\s*TA4J_VERSION_BLOCK:examples:snapshot:begin\s*-->)
|
||||
(.*?)
|
||||
(<!--\s*TA4J_VERSION_BLOCK:examples:snapshot:end\s*-->)
|
||||
}{$1 . bump_version_tags($2, $ENV{SNAPSHOT_VERSION}) . $3}gsex;
|
||||
' README.md
|
||||
|
||||
perl -0pi -e "s|Current version: \`[0-9]+\\.[0-9]+(\\.[0-9]+)?\`|Current version: \`${version}\`|g" README.md || true
|
||||
|
||||
if ! grep -Fq "<version>${VERSION}</version>" README.md; then
|
||||
echo "Error: expected release version ${VERSION} to appear in README.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "<version>${SNAPSHOT_VERSION}</version>" README.md; then
|
||||
echo "Error: expected snapshot version ${SNAPSHOT_VERSION} to appear in README.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
|
||||
echo "Preparing release: $RELEASE_VERSION"
|
||||
|
||||
require_readme_sentinels
|
||||
SNAPSHOT_VERSION="$(compute_snapshot_version "$RELEASE_VERSION")"
|
||||
update_changelog "$RELEASE_VERSION" "$RELEASE_NOTES_FILE"
|
||||
update_readme "$RELEASE_VERSION" "$SNAPSHOT_VERSION"
|
||||
|
||||
echo
|
||||
echo "release_version=${RELEASE_VERSION}"
|
||||
echo "release_notes_file=${RELEASE_NOTES_FILE}"
|
||||
@@ -0,0 +1,15 @@
|
||||
# Known Javadoc warnings accepted during release publishing.
|
||||
# Keep this file empty unless a warning is intentionally carried as release debt.
|
||||
ta4j-core/src/main/java/org/ta4j/core/indicators/ChopIndicator.java: warning: unknown tag. Unregistered custom tag?
|
||||
ta4j-core/src/main/java/org/ta4j/core/analysis/CumulativePnL.java: warning: reference not found: org.ta4j.core.PerformanceIndicator
|
||||
ta4j-core/src/main/java/org/ta4j/core/num/DoubleNum.java: warning: unknown tag. Unregistered custom tag?
|
||||
ta4j-core/src/main/java/org/ta4j/core/indicators/elliott/ElliottScenarioSet.java: warning: invalid input: '<'
|
||||
ta4j-core/src/main/java/org/ta4j/core/indicators/FisherIndicator.java: warning: unknown tag. Unregistered custom tag?
|
||||
ta4j-core/src/main/java/org/ta4j/core/criteria/drawdown/ReturnOverMaxDrawdownCriterion.java: warning: reference not found: NetReturnCriterion
|
||||
ta4j-core/src/main/java/org/ta4j/core/criteria/drawdown/ReturnOverMaxDrawdownCriterion.java: warning: reference not found: ReturnRepresentationPolicy
|
||||
ta4j-core/src/main/java/org/ta4j/core/criteria/ReturnRepresentation.java: warning: reference not found: Returns
|
||||
ta4j-examples/src/main/java/ta4jexamples/backtesting/CoinbaseBacktest.java: warning: invalid input: '&'
|
||||
ta4j-examples/src/main/java/ta4jexamples/analysis/elliottwave/ElliottWaveAnalysisReport.java: warning: reference not found: ElliottWaveIndicatorSuiteDemo#logBaseCaseScenario(ElliottScenario)
|
||||
ta4j-examples/src/main/java/ta4jexamples/analysis/elliottwave/ElliottWaveAnalysisReport.java: warning: reference not found: ElliottWaveIndicatorSuiteDemo#logAlternativeScenarios(List)
|
||||
ta4j-examples/src/main/java/ta4jexamples/datasources/json/JsonBarsSerializer.java: warning: reference not found: JsonFileBarSeriesDataSource
|
||||
ta4j-examples/src/main/java/ta4jexamples/datasources/json/JsonBarsSerializer.java: warning: reference not found: JsonFileBarSeriesDataSource#loadSeries(String)
|
||||
@@ -0,0 +1,941 @@
|
||||
#!/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())
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# =============================================================================
|
||||
# resolve-release-tags.sh
|
||||
#
|
||||
# Emits release-tag state as key=value lines for the supplied target ref.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/resolve-release-tags.sh [target-ref]
|
||||
#
|
||||
# Output:
|
||||
# latest_tag=<tag|none>
|
||||
# last_reachable_tag=<tag|none>
|
||||
# last_first_parent_tag=<tag|none>
|
||||
# latest_tag_reachable=<true|false|n/a>
|
||||
# =============================================================================
|
||||
|
||||
target_ref="${1:-HEAD}"
|
||||
|
||||
if ! git rev-parse --verify --quiet "${target_ref}^{commit}" >/dev/null; then
|
||||
target_ref="HEAD"
|
||||
fi
|
||||
|
||||
list_tags() {
|
||||
local merged_ref="${1:-}"
|
||||
|
||||
if [ -n "$merged_ref" ]; then
|
||||
git for-each-ref --sort=-creatordate --merged "$merged_ref" --format='%(refname:strip=2)' refs/tags
|
||||
else
|
||||
git for-each-ref --sort=-creatordate --format='%(refname:strip=2)' refs/tags
|
||||
fi
|
||||
}
|
||||
|
||||
find_preferred_release_tag() {
|
||||
local merged_ref="${1:-}"
|
||||
local selected_tag="none"
|
||||
local tag=""
|
||||
local tag_list=""
|
||||
|
||||
tag_list="$(list_tags "$merged_ref")"
|
||||
|
||||
if [ -n "$tag_list" ]; then
|
||||
while IFS= read -r tag; do
|
||||
case "$tag" in
|
||||
[0-9]*)
|
||||
selected_tag="$tag"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done <<< "$tag_list"
|
||||
|
||||
if [ "$selected_tag" = "none" ]; then
|
||||
while IFS= read -r tag; do
|
||||
case "$tag" in
|
||||
v[0-9]*)
|
||||
selected_tag="$tag"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done <<< "$tag_list"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%s\n' "$selected_tag"
|
||||
}
|
||||
|
||||
describe_first_parent_release_tag() {
|
||||
local ref="$1"
|
||||
local tag=""
|
||||
|
||||
tag=$(git describe --tags --abbrev=0 --first-parent --match '[0-9]*' "$ref" 2>/dev/null || true)
|
||||
if [ -z "$tag" ]; then
|
||||
tag=$(git describe --tags --abbrev=0 --first-parent --match 'v[0-9]*' "$ref" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -z "$tag" ]; then
|
||||
printf 'none\n'
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s\n' "$tag"
|
||||
}
|
||||
|
||||
latest_tag="$(find_preferred_release_tag)"
|
||||
last_reachable_tag="$(find_preferred_release_tag "$target_ref")"
|
||||
last_first_parent_tag="$(describe_first_parent_release_tag "$target_ref")"
|
||||
latest_tag_reachable="n/a"
|
||||
|
||||
if [ "$latest_tag" != "none" ]; then
|
||||
if git merge-base --is-ancestor "$latest_tag" "$target_ref"; then
|
||||
latest_tag_reachable="true"
|
||||
else
|
||||
latest_tag_reachable="false"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'latest_tag=%s\n' "$latest_tag"
|
||||
printf 'last_reachable_tag=%s\n' "$last_reachable_tag"
|
||||
printf 'last_first_parent_tag=%s\n' "$last_first_parent_tag"
|
||||
printf 'latest_tag_reachable=%s\n' "$latest_tag_reachable"
|
||||
Executable
+401
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
UNAME_OUT="$(uname -s 2>/dev/null || echo unknown)"
|
||||
RUNNING_IN_MINGW="false"
|
||||
case "$UNAME_OUT" in
|
||||
CYGWIN*|MSYS*|MINGW*) RUNNING_IN_MINGW="true" ;;
|
||||
esac
|
||||
|
||||
to_host_path() {
|
||||
local posix_path="$1"
|
||||
if [[ "$RUNNING_IN_MINGW" == "true" ]]; then
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
cygpath -w "$posix_path"
|
||||
return
|
||||
fi
|
||||
if [[ "$posix_path" =~ ^/([a-zA-Z])/(.*)$ ]]; then
|
||||
local drive="${BASH_REMATCH[1]}"
|
||||
local remainder="${BASH_REMATCH[2]}"
|
||||
drive="$(printf '%s' "$drive" | tr '[:lower:]' '[:upper:]')"
|
||||
if [[ -n "$remainder" ]]; then
|
||||
printf '%s:/%s' "$drive" "$remainder"
|
||||
else
|
||||
printf '%s:/' "$drive"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
fi
|
||||
printf '%s' "$posix_path"
|
||||
}
|
||||
|
||||
TMP_FILES=()
|
||||
build_runner_pid=""
|
||||
heartbeat_pid=""
|
||||
cleanup() {
|
||||
if [[ -n "${build_runner_pid:-}" ]] && kill -0 "$build_runner_pid" >/dev/null 2>&1; then
|
||||
kill "$build_runner_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${heartbeat_pid:-}" ]] && kill -0 "$heartbeat_pid" >/dev/null 2>&1; then
|
||||
kill "$heartbeat_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${build_runner_pid:-}" ]]; then
|
||||
wait "$build_runner_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${heartbeat_pid:-}" ]]; then
|
||||
wait "$heartbeat_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
local file
|
||||
for file in "${TMP_FILES[@]:-}"; do
|
||||
if [[ -n "${file:-}" && -e "$file" ]]; then
|
||||
rm -f "$file"
|
||||
fi
|
||||
done
|
||||
}
|
||||
trap cleanup SIGINT SIGTERM EXIT
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
LOG_DIR="$REPO_ROOT/.agents/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# Clean up old full build logs, keeping only the last 10
|
||||
cleanup_old_logs() {
|
||||
local log_dir="$1"
|
||||
local keep_count=10
|
||||
if [[ ! -d "$log_dir" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Find all full-build-*.log files and sort by filename (which contains timestamp)
|
||||
# Filenames are in format: full-build-YYYYMMDD-HHMMSS.log
|
||||
# Sorting alphabetically gives chronological order (newest last)
|
||||
local files
|
||||
files=$(find "$log_dir" -maxdepth 1 -type f -name "full-build-*.log" 2>/dev/null | sort || true)
|
||||
if [[ -z "$files" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Delete everything except the newest keep_count files; use POSIX-safe tail/awk
|
||||
local file_count
|
||||
file_count=$(printf '%s\n' "$files" | grep -c . || true)
|
||||
local delete_count=$((file_count - keep_count))
|
||||
if ((delete_count > 0)); then
|
||||
local files_to_delete
|
||||
files_to_delete=$(printf '%s\n' "$files" | head -n "$delete_count")
|
||||
while IFS= read -r file; do
|
||||
if [[ -n "$file" && -f "$file" ]]; then
|
||||
rm -f "$file"
|
||||
fi
|
||||
done <<< "$files_to_delete"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_old_logs "$LOG_DIR"
|
||||
|
||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
LOG_FILE="$LOG_DIR/full-build-${TIMESTAMP}.log"
|
||||
LOG_FILE_FOR_DISPLAY="$LOG_FILE"
|
||||
if [[ "$RUNNING_IN_MINGW" == "true" ]]; then
|
||||
LOG_FILE_FOR_DISPLAY="$(to_host_path "$LOG_FILE")"
|
||||
fi
|
||||
|
||||
BUILD_TIMEOUT_SECONDS="${QUIET_BUILD_TIMEOUT_SECONDS:-180}"
|
||||
if ! [[ "$BUILD_TIMEOUT_SECONDS" =~ ^[0-9]+$ ]]; then
|
||||
BUILD_TIMEOUT_SECONDS=180
|
||||
fi
|
||||
|
||||
HEARTBEAT_INTERVAL_SECONDS="${QUIET_BUILD_HEARTBEAT_SECONDS:-60}"
|
||||
if ! [[ "$HEARTBEAT_INTERVAL_SECONDS" =~ ^[0-9]+$ ]] || ((HEARTBEAT_INTERVAL_SECONDS < 1)); then
|
||||
HEARTBEAT_INTERVAL_SECONDS=60
|
||||
fi
|
||||
|
||||
MAVEN_FLAGS=(
|
||||
-B
|
||||
-ntp
|
||||
-Dstyle.color=never
|
||||
-Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=WARN
|
||||
)
|
||||
|
||||
EXTRA_MAVEN_ARGS=()
|
||||
if (($# > 0)); then
|
||||
EXTRA_MAVEN_ARGS=("$@")
|
||||
fi
|
||||
|
||||
if ((${#EXTRA_MAVEN_ARGS[@]} > 0)); then
|
||||
printf 'Forwarding extra Maven args:'
|
||||
for arg in "${EXTRA_MAVEN_ARGS[@]}"; do
|
||||
printf ' %q' "$arg"
|
||||
done
|
||||
printf '\n'
|
||||
fi
|
||||
|
||||
GOALS=(clean license:format formatter:format test install)
|
||||
MAVEN_CMD_PREFIX=(mvn)
|
||||
if [[ -x "./mvnw" ]]; then
|
||||
MAVEN_CMD_PREFIX=("./mvnw")
|
||||
echo "Using Maven Wrapper: ./mvnw"
|
||||
elif [[ -f "./mvnw" ]]; then
|
||||
MAVEN_CMD_PREFIX=(sh "./mvnw")
|
||||
echo "Using Maven Wrapper via sh: ./mvnw"
|
||||
else
|
||||
echo "Using system Maven from PATH: mvn"
|
||||
fi
|
||||
|
||||
MVN_CMD=("${MAVEN_CMD_PREFIX[@]}" "${MAVEN_FLAGS[@]}")
|
||||
if ((${#EXTRA_MAVEN_ARGS[@]} > 0)); then
|
||||
MVN_CMD+=("${EXTRA_MAVEN_ARGS[@]}")
|
||||
fi
|
||||
MVN_CMD+=("${GOALS[@]}")
|
||||
|
||||
echo "Running ta4j full build quietly..."
|
||||
echo "Full log: $LOG_FILE_FOR_DISPLAY"
|
||||
echo
|
||||
|
||||
format_elapsed() {
|
||||
local total_seconds="$1"
|
||||
local hours=$((total_seconds / 3600))
|
||||
local minutes=$(((total_seconds % 3600) / 60))
|
||||
local seconds=$((total_seconds % 60))
|
||||
if ((hours > 0)); then
|
||||
printf '%dh%02dm%02ds' "$hours" "$minutes" "$seconds"
|
||||
elif ((minutes > 0)); then
|
||||
printf '%dm%02ds' "$minutes" "$seconds"
|
||||
else
|
||||
printf '%ds' "$seconds"
|
||||
fi
|
||||
}
|
||||
|
||||
should_print_line() {
|
||||
local text="$1"
|
||||
case "$text" in
|
||||
*"[ERROR]"*|*"[WARNING]"*|*"BUILD SUCCESS"*|*"BUILD FAILURE"*|*"Failed to execute goal"*|*"Reactor Summary"*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
trim_whitespace() {
|
||||
local value="$1"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
run_with_timeout() {
|
||||
local timeout_marker_file="$1"
|
||||
local timeout_seconds="$2"
|
||||
shift 2
|
||||
: >"$timeout_marker_file"
|
||||
|
||||
"$@" &
|
||||
local command_pid=$!
|
||||
|
||||
(
|
||||
local elapsed=0
|
||||
while ((elapsed < timeout_seconds)); do
|
||||
sleep 1
|
||||
if ! kill -0 "$command_pid" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
if kill -0 "$command_pid" >/dev/null 2>&1; then
|
||||
echo "timeout" >"$timeout_marker_file"
|
||||
kill -TERM "$command_pid" >/dev/null 2>&1 || true
|
||||
sleep 5
|
||||
if kill -0 "$command_pid" >/dev/null 2>&1; then
|
||||
kill -KILL "$command_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
) &
|
||||
local timeout_watcher_pid=$!
|
||||
|
||||
set +e
|
||||
wait "$command_pid"
|
||||
local command_status=$?
|
||||
set -e
|
||||
|
||||
wait "$timeout_watcher_pid" >/dev/null 2>&1 || true
|
||||
|
||||
if [[ -s "$timeout_marker_file" ]]; then
|
||||
return 124
|
||||
fi
|
||||
return "$command_status"
|
||||
}
|
||||
|
||||
update_last_visible() {
|
||||
date +%s >"$LAST_VISIBLE_FILE"
|
||||
}
|
||||
|
||||
heartbeat_worker() {
|
||||
local build_pid="$1"
|
||||
local start_time="$2"
|
||||
local heartbeat_interval="$3"
|
||||
while kill -0 "$build_pid" >/dev/null 2>&1; do
|
||||
sleep "$heartbeat_interval"
|
||||
if ! kill -0 "$build_pid" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
local now
|
||||
now="$(date +%s)"
|
||||
local last_visible="$start_time"
|
||||
if [[ -f "$LAST_VISIBLE_FILE" ]]; then
|
||||
last_visible="$(cat "$LAST_VISIBLE_FILE" 2>/dev/null || echo "$start_time")"
|
||||
fi
|
||||
if ! [[ "$last_visible" =~ ^[0-9]+$ ]]; then
|
||||
last_visible="$start_time"
|
||||
fi
|
||||
if ((now - last_visible >= heartbeat_interval)); then
|
||||
echo "[quiet-build] still running... ($(format_elapsed $((now - start_time))))"
|
||||
update_last_visible
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
run_maven_build() {
|
||||
if ((BUILD_TIMEOUT_SECONDS > 0)); then
|
||||
run_with_timeout "$TIMEOUT_MARKER_FILE" "$BUILD_TIMEOUT_SECONDS" "${MVN_CMD[@]}"
|
||||
return $?
|
||||
fi
|
||||
"${MVN_CMD[@]}"
|
||||
}
|
||||
|
||||
emit_suppressed_duplicates() {
|
||||
if [[ ! -s "$SUPPRESSED_DUPLICATES_FILE" ]]; then
|
||||
return 0
|
||||
fi
|
||||
echo
|
||||
echo "[quiet-build] Suppressed duplicate warnings:"
|
||||
local line
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
if grep -Fqx -- "$line" "$SUPPRESSED_REPORTED_FILE"; then
|
||||
continue
|
||||
fi
|
||||
local count
|
||||
count=$(grep -Fxc -- "$line" "$SUPPRESSED_DUPLICATES_FILE" || true)
|
||||
echo "[quiet-build] (${count} more) $(trim_whitespace "$line")"
|
||||
printf '%s\n' "$line" >>"$SUPPRESSED_REPORTED_FILE"
|
||||
done <"$SUPPRESSED_DUPLICATES_FILE"
|
||||
}
|
||||
|
||||
extract_test_summary() {
|
||||
local total_run=0
|
||||
local total_failures=0
|
||||
local total_errors=0
|
||||
local total_skipped=0
|
||||
local has_aggregated="false"
|
||||
local fallback_summary=""
|
||||
local line
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
if [[ "$line" =~ ^\[(INFO|WARNING)\][[:space:]]+Tests\ run:\ ([0-9]+),\ Failures:\ ([0-9]+),\ Errors:\ ([0-9]+),\ Skipped:\ ([0-9]+)[[:space:]]*$ ]]; then
|
||||
has_aggregated="true"
|
||||
total_run=$((total_run + ${BASH_REMATCH[2]}))
|
||||
total_failures=$((total_failures + ${BASH_REMATCH[3]}))
|
||||
total_errors=$((total_errors + ${BASH_REMATCH[4]}))
|
||||
total_skipped=$((total_skipped + ${BASH_REMATCH[5]}))
|
||||
elif [[ "$line" =~ Tests\ run:\ ([0-9]+),\ Failures:\ ([0-9]+),\ Errors:\ ([0-9]+),\ Skipped:\ ([0-9]+) ]]; then
|
||||
fallback_summary="Tests run: ${BASH_REMATCH[1]}, Failures: ${BASH_REMATCH[2]}, Errors: ${BASH_REMATCH[3]}, Skipped: ${BASH_REMATCH[4]}"
|
||||
fi
|
||||
done <"$LOG_FILE"
|
||||
|
||||
if [[ "$has_aggregated" == "true" ]]; then
|
||||
printf 'Tests run: %d, Failures: %d, Errors: %d, Skipped: %d' "$total_run" "$total_failures" "$total_errors" "$total_skipped"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$fallback_summary" ]]; then
|
||||
printf '%s' "$fallback_summary"
|
||||
fi
|
||||
}
|
||||
|
||||
LAST_VISIBLE_FILE="$(mktemp "${TMPDIR:-/tmp}/ta4j-quiet-build-last-visible.XXXXXX")"
|
||||
TMP_FILES+=("$LAST_VISIBLE_FILE")
|
||||
BUILD_START_TIME="$(date +%s)"
|
||||
echo "$BUILD_START_TIME" >"$LAST_VISIBLE_FILE"
|
||||
|
||||
SEEN_VISIBLE_LINES_FILE="$(mktemp "${TMPDIR:-/tmp}/ta4j-quiet-build-seen-visible.XXXXXX")"
|
||||
TMP_FILES+=("$SEEN_VISIBLE_LINES_FILE")
|
||||
|
||||
SUPPRESSED_DUPLICATES_FILE="$(mktemp "${TMPDIR:-/tmp}/ta4j-quiet-build-suppressed-duplicates.XXXXXX")"
|
||||
TMP_FILES+=("$SUPPRESSED_DUPLICATES_FILE")
|
||||
|
||||
SUPPRESSED_REPORTED_FILE="$(mktemp "${TMPDIR:-/tmp}/ta4j-quiet-build-suppressed-reported.XXXXXX")"
|
||||
TMP_FILES+=("$SUPPRESSED_REPORTED_FILE")
|
||||
|
||||
OUTPUT_FIFO="$(mktemp "${TMPDIR:-/tmp}/ta4j-quiet-build-output.XXXXXX")"
|
||||
TMP_FILES+=("$OUTPUT_FIFO")
|
||||
rm -f "$OUTPUT_FIFO"
|
||||
mkfifo "$OUTPUT_FIFO"
|
||||
|
||||
TIMEOUT_MARKER_FILE=""
|
||||
if ((BUILD_TIMEOUT_SECONDS > 0)); then
|
||||
TIMEOUT_MARKER_FILE="$(mktemp "${TMPDIR:-/tmp}/ta4j-quiet-build-timeout-marker.XXXXXX")"
|
||||
TMP_FILES+=("$TIMEOUT_MARKER_FILE")
|
||||
fi
|
||||
|
||||
: >"$LOG_FILE"
|
||||
: >"$SEEN_VISIBLE_LINES_FILE"
|
||||
: >"$SUPPRESSED_DUPLICATES_FILE"
|
||||
: >"$SUPPRESSED_REPORTED_FILE"
|
||||
|
||||
set +o pipefail
|
||||
set +e
|
||||
run_maven_build >"$OUTPUT_FIFO" 2>&1 &
|
||||
build_runner_pid=$!
|
||||
|
||||
heartbeat_worker "$build_runner_pid" "$BUILD_START_TIME" "$HEARTBEAT_INTERVAL_SECONDS" &
|
||||
heartbeat_pid=$!
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
printf '%s\n' "$line" >>"$LOG_FILE"
|
||||
if ! should_print_line "$line"; then
|
||||
continue
|
||||
fi
|
||||
if [[ "$line" == *"Tests run:"* && "$line" == *"Time elapsed"* ]]; then
|
||||
continue
|
||||
fi
|
||||
if grep -Fqx -- "$line" "$SEEN_VISIBLE_LINES_FILE"; then
|
||||
printf '%s\n' "$line" >>"$SUPPRESSED_DUPLICATES_FILE"
|
||||
else
|
||||
printf '%s\n' "$line" >>"$SEEN_VISIBLE_LINES_FILE"
|
||||
printf '%s\n' "$line"
|
||||
update_last_visible
|
||||
fi
|
||||
done <"$OUTPUT_FIFO"
|
||||
filter_status=$?
|
||||
|
||||
wait "$build_runner_pid"
|
||||
mvn_status=$?
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
kill "$heartbeat_pid" >/dev/null 2>&1 || true
|
||||
wait "$heartbeat_pid" >/dev/null 2>&1 || true
|
||||
|
||||
emit_suppressed_duplicates
|
||||
|
||||
if ((mvn_status != 0 || filter_status != 0)); then
|
||||
echo
|
||||
if ((mvn_status == 124)); then
|
||||
echo "Maven build timed out after ${BUILD_TIMEOUT_SECONDS}s. Inspect the full log at: $LOG_FILE_FOR_DISPLAY"
|
||||
exit 1
|
||||
fi
|
||||
echo "Maven build failed (mvn=$mvn_status, filter=$filter_status). Inspect the full log at: $LOG_FILE_FOR_DISPLAY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
summary="$(extract_test_summary)"
|
||||
|
||||
echo
|
||||
if [[ -n "$summary" ]]; then
|
||||
echo "$summary"
|
||||
else
|
||||
echo "Tests run summary not found in log; see $LOG_FILE_FOR_DISPLAY"
|
||||
fi
|
||||
echo "Full build log saved to: $LOG_FILE_FOR_DISPLAY"
|
||||
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# =============================================================================
|
||||
# test_prepare_release.sh
|
||||
#
|
||||
# Tests the responsibilities of prepare-release.sh:
|
||||
# - CHANGELOG transformation
|
||||
# - README version updates
|
||||
# - Release notes file creation
|
||||
# - Output variables
|
||||
# =============================================================================
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$ROOT/scripts/prepare-release.sh"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${TMP:-}" && -d "$TMP" ]]; then
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() { echo "[FAIL] $1" >&2; exit 1; }
|
||||
pass() { echo "[PASS] $1"; }
|
||||
|
||||
expect_contains() {
|
||||
local haystack="$1"
|
||||
local needle="$2"
|
||||
local msg="$3"
|
||||
if ! grep -Fq "$needle" <<<"$haystack"; then
|
||||
fail "$msg (missing: '$needle')"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_file_contains() {
|
||||
local file="$1"
|
||||
local needle="$2"
|
||||
local msg="$3"
|
||||
if ! grep -Fq -- "$needle" "$file"; then
|
||||
fail "$msg (missing: '$needle')"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_file_matches() {
|
||||
local file="$1"
|
||||
local regex="$2"
|
||||
local msg="$3"
|
||||
if ! grep -Eq -- "$regex" "$file"; then
|
||||
fail "$msg (regex mismatch: '$regex')"
|
||||
fi
|
||||
}
|
||||
|
||||
run_test() {
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/prepare-release-test.XXXXXX")"
|
||||
mkdir -p "$TMP/scripts"
|
||||
cp "$SCRIPT" "$TMP/scripts/prepare-release.sh"
|
||||
chmod +x "$TMP/scripts/prepare-release.sh"
|
||||
|
||||
pushd "$TMP" >/dev/null || exit 1
|
||||
}
|
||||
|
||||
finish_test() {
|
||||
popd >/dev/null || exit 1
|
||||
rm -rf "$TMP"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# TEST 1: Basic Release Flow
|
||||
# -----------------------------------------------------------------------------
|
||||
test_basic_flow() {
|
||||
echo "Running test_basic_flow"
|
||||
|
||||
run_test
|
||||
|
||||
cat > CHANGELOG.md <<'EOF'
|
||||
## Unreleased
|
||||
|
||||
- Added new Indicator ABC.
|
||||
|
||||
## 1.2.3 (2024-01-01)
|
||||
- Older note
|
||||
EOF
|
||||
|
||||
cat > README.md <<'EOF'
|
||||
<!-- TA4J_VERSION_BLOCK:core:stable:begin -->
|
||||
<dependency>
|
||||
<artifactId>ta4j-core</artifactId>
|
||||
<version>0.0.1</version>
|
||||
</dependency>
|
||||
<!-- TA4J_VERSION_BLOCK:core:stable:end -->
|
||||
|
||||
<!-- TA4J_VERSION_BLOCK:core:snapshot:begin -->
|
||||
<dependency>
|
||||
<artifactId>ta4j-core</artifactId>
|
||||
<version>0.0.2-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<!-- TA4J_VERSION_BLOCK:core:snapshot:end -->
|
||||
|
||||
<!-- TA4J_VERSION_BLOCK:examples:stable:begin -->
|
||||
<dependency>
|
||||
<artifactId>ta4j-examples</artifactId>
|
||||
<version>0.0.1</version>
|
||||
</dependency>
|
||||
<!-- TA4J_VERSION_BLOCK:examples:stable:end -->
|
||||
|
||||
<!-- TA4J_VERSION_BLOCK:examples:snapshot:begin -->
|
||||
<dependency>
|
||||
<artifactId>ta4j-examples</artifactId>
|
||||
<version>0.0.2-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<!-- TA4J_VERSION_BLOCK:examples:snapshot:end -->
|
||||
EOF
|
||||
|
||||
OUT="$(scripts/prepare-release.sh 1.3.0)"
|
||||
|
||||
expect_contains "$OUT" "release_version=1.3.0" "script should print release version"
|
||||
expect_contains "$OUT" "release_notes_file=release/1.3.0.md" "script should print release notes file path"
|
||||
|
||||
expect_file_contains CHANGELOG.md "## Unreleased" "Unreleased should be preserved"
|
||||
expect_file_contains CHANGELOG.md "- _No changes yet._" "Unreleased should be reset with placeholder"
|
||||
|
||||
expect_file_matches CHANGELOG.md "## 1\\.3\\.0 \\([0-9]{4}-[0-9]{2}-[0-9]{2}\\)" "new release header should exist"
|
||||
expect_file_contains CHANGELOG.md "Added new Indicator ABC." "notes should move into release section"
|
||||
|
||||
expect_file_contains release/1.3.0.md "Added new Indicator ABC." "release notes file should include notes"
|
||||
expect_file_matches release/1.3.0.md "^## 1\\.3\\.0 \\([0-9]{4}-[0-9]{2}-[0-9]{2}\\)" "release notes should have correct header"
|
||||
|
||||
expect_file_contains README.md "<version>1.3.0</version>" "README version should be updated"
|
||||
expect_file_contains README.md "<version>1.3.1-SNAPSHOT</version>" "README snapshot version should be incremented and preserved"
|
||||
|
||||
finish_test
|
||||
pass "test_basic_flow"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# TEST 2: Missing Unreleased Section
|
||||
# -----------------------------------------------------------------------------
|
||||
test_missing_unreleased() {
|
||||
echo "Running test_missing_unreleased"
|
||||
|
||||
run_test
|
||||
|
||||
cat > CHANGELOG.md <<'EOF'
|
||||
## 1.2.0 (2023-12-01)
|
||||
- Old release
|
||||
EOF
|
||||
|
||||
cat > README.md <<'EOF'
|
||||
<!-- TA4J_VERSION_BLOCK:core:stable:begin -->
|
||||
<version>0.0.1</version>
|
||||
<!-- TA4J_VERSION_BLOCK:core:stable:end -->
|
||||
<!-- TA4J_VERSION_BLOCK:core:snapshot:begin -->
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<!-- TA4J_VERSION_BLOCK:core:snapshot:end -->
|
||||
<!-- TA4J_VERSION_BLOCK:examples:stable:begin -->
|
||||
<version>0.0.1</version>
|
||||
<!-- TA4J_VERSION_BLOCK:examples:stable:end -->
|
||||
<!-- TA4J_VERSION_BLOCK:examples:snapshot:begin -->
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<!-- TA4J_VERSION_BLOCK:examples:snapshot:end -->
|
||||
EOF
|
||||
|
||||
OUT="$(scripts/prepare-release.sh 1.3.0)"
|
||||
|
||||
expect_file_contains CHANGELOG.md "## Unreleased" "should create Unreleased"
|
||||
expect_file_contains CHANGELOG.md "## 1.3.0" "should add release section"
|
||||
|
||||
finish_test
|
||||
pass "test_missing_unreleased"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# TEST 3: Empty CHANGELOG
|
||||
# -----------------------------------------------------------------------------
|
||||
test_empty_changelog() {
|
||||
echo "Running test_empty_changelog"
|
||||
|
||||
run_test
|
||||
|
||||
touch CHANGELOG.md
|
||||
cat > README.md <<'EOF'
|
||||
<!-- TA4J_VERSION_BLOCK:core:stable:begin -->
|
||||
<version>0.0.1</version>
|
||||
<!-- TA4J_VERSION_BLOCK:core:stable:end -->
|
||||
<!-- TA4J_VERSION_BLOCK:core:snapshot:begin -->
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<!-- TA4J_VERSION_BLOCK:core:snapshot:end -->
|
||||
<!-- TA4J_VERSION_BLOCK:examples:stable:begin -->
|
||||
<version>0.0.1</version>
|
||||
<!-- TA4J_VERSION_BLOCK:examples:stable:end -->
|
||||
<!-- TA4J_VERSION_BLOCK:examples:snapshot:begin -->
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<!-- TA4J_VERSION_BLOCK:examples:snapshot:end -->
|
||||
EOF
|
||||
|
||||
OUT="$(scripts/prepare-release.sh 1.0.0)"
|
||||
|
||||
expect_file_contains CHANGELOG.md "## Unreleased" "should add Unreleased"
|
||||
expect_file_contains CHANGELOG.md "## 1.0.0" "should add release section"
|
||||
expect_file_contains CHANGELOG.md "- _No changes yet._" "should add placeholder"
|
||||
|
||||
finish_test
|
||||
pass "test_empty_changelog"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# TEST 4: Snapshot Version Increment (major.minor.patch)
|
||||
# -----------------------------------------------------------------------------
|
||||
test_snapshot_patch_increment() {
|
||||
echo "Running test_snapshot_patch_increment"
|
||||
|
||||
run_test
|
||||
|
||||
cat > CHANGELOG.md <<'EOF'
|
||||
## Unreleased
|
||||
|
||||
- Some change.
|
||||
EOF
|
||||
|
||||
cat > README.md <<'EOF'
|
||||
<!-- TA4J_VERSION_BLOCK:core:stable:begin -->
|
||||
<dependency>
|
||||
<artifactId>ta4j-core</artifactId>
|
||||
<version>0.1</version>
|
||||
</dependency>
|
||||
<!-- TA4J_VERSION_BLOCK:core:stable:end -->
|
||||
|
||||
<!-- TA4J_VERSION_BLOCK:core:snapshot:begin -->
|
||||
<dependency>
|
||||
<artifactId>ta4j-core</artifactId>
|
||||
<version>0.2-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<!-- TA4J_VERSION_BLOCK:core:snapshot:end -->
|
||||
|
||||
<!-- TA4J_VERSION_BLOCK:examples:stable:begin -->
|
||||
<dependency>
|
||||
<artifactId>ta4j-examples</artifactId>
|
||||
<version>0.1</version>
|
||||
</dependency>
|
||||
<!-- TA4J_VERSION_BLOCK:examples:stable:end -->
|
||||
|
||||
<!-- TA4J_VERSION_BLOCK:examples:snapshot:begin -->
|
||||
<dependency>
|
||||
<artifactId>ta4j-examples</artifactId>
|
||||
<version>0.2-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<!-- TA4J_VERSION_BLOCK:examples:snapshot:end -->
|
||||
EOF
|
||||
|
||||
scripts/prepare-release.sh 2.7.4 >/dev/null
|
||||
|
||||
expect_file_contains README.md "<version>2.7.4</version>" "README stable version should be updated"
|
||||
expect_file_contains README.md "<version>2.7.5-SNAPSHOT</version>" "README snapshot version should increment patch and preserve suffix"
|
||||
|
||||
finish_test
|
||||
pass "test_snapshot_patch_increment"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# TEST 5: Missing README Sentinel
|
||||
# -----------------------------------------------------------------------------
|
||||
test_missing_readme_sentinel() {
|
||||
echo "Running test_missing_readme_sentinel"
|
||||
|
||||
run_test
|
||||
|
||||
cat > CHANGELOG.md <<'EOF'
|
||||
## Unreleased
|
||||
|
||||
- Some change.
|
||||
EOF
|
||||
|
||||
cat > README.md <<'EOF'
|
||||
<!-- TA4J_VERSION_BLOCK:core:stable:begin -->
|
||||
<version>0.1</version>
|
||||
<!-- TA4J_VERSION_BLOCK:core:stable:end -->
|
||||
EOF
|
||||
|
||||
if scripts/prepare-release.sh 1.0.0 >/dev/null 2>&1; then
|
||||
fail "script should fail when README sentinels are missing"
|
||||
fi
|
||||
|
||||
finish_test
|
||||
pass "test_missing_readme_sentinel"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# TEST 6: Invalid Release Version
|
||||
# -----------------------------------------------------------------------------
|
||||
test_invalid_release_version() {
|
||||
echo "Running test_invalid_release_version"
|
||||
|
||||
run_test
|
||||
|
||||
cat > CHANGELOG.md <<'EOF'
|
||||
## Unreleased
|
||||
|
||||
- Some change.
|
||||
EOF
|
||||
|
||||
cat > README.md <<'EOF'
|
||||
<!-- TA4J_VERSION_BLOCK:core:stable:begin -->
|
||||
<version>0.1</version>
|
||||
<!-- TA4J_VERSION_BLOCK:core:stable:end -->
|
||||
<!-- TA4J_VERSION_BLOCK:core:snapshot:begin -->
|
||||
<version>0.1-SNAPSHOT</version>
|
||||
<!-- TA4J_VERSION_BLOCK:core:snapshot:end -->
|
||||
<!-- TA4J_VERSION_BLOCK:examples:stable:begin -->
|
||||
<version>0.1</version>
|
||||
<!-- TA4J_VERSION_BLOCK:examples:stable:end -->
|
||||
<!-- TA4J_VERSION_BLOCK:examples:snapshot:begin -->
|
||||
<version>0.1-SNAPSHOT</version>
|
||||
<!-- TA4J_VERSION_BLOCK:examples:snapshot:end -->
|
||||
EOF
|
||||
|
||||
if scripts/prepare-release.sh 1.3 >/dev/null 2>&1; then
|
||||
fail "script should fail when release version is not major.minor.patch"
|
||||
fi
|
||||
|
||||
expect_file_contains CHANGELOG.md "## Unreleased" "changelog should remain unchanged on version error"
|
||||
expect_file_contains CHANGELOG.md "- Some change." "changelog entries should remain unchanged on version error"
|
||||
|
||||
finish_test
|
||||
pass "test_invalid_release_version"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
main() {
|
||||
test_basic_flow
|
||||
test_missing_unreleased
|
||||
test_empty_changelog
|
||||
test_snapshot_patch_increment
|
||||
test_missing_readme_sentinel
|
||||
test_invalid_release_version
|
||||
|
||||
echo "All tests passed."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
WORKFLOW="$ROOT/.github/workflows/prepare-release.yml"
|
||||
|
||||
fail() { echo "[FAIL] $1" >&2; exit 1; }
|
||||
pass() { echo "[PASS] $1"; }
|
||||
|
||||
expect_file_contains() {
|
||||
local file="$1"
|
||||
local needle="$2"
|
||||
local msg="$3"
|
||||
if ! grep -Fq -- "$needle" "$file"; then
|
||||
fail "$msg (missing: '$needle')"
|
||||
fi
|
||||
}
|
||||
|
||||
line_of() {
|
||||
local needle="$1"
|
||||
local line
|
||||
line="$(grep -nF -- "$needle" "$WORKFLOW" | head -n1 | cut -d: -f1)"
|
||||
if [[ -z "$line" ]]; then
|
||||
fail "missing workflow line: '$needle'"
|
||||
fi
|
||||
printf '%s\n' "$line"
|
||||
}
|
||||
|
||||
workflow_section() {
|
||||
local start="$1"
|
||||
local end="$2"
|
||||
awk -v start="$start" -v end="$end" '
|
||||
index($0, start) { active = 1 }
|
||||
active { print }
|
||||
index($0, end) { active = 0 }
|
||||
' "$WORKFLOW"
|
||||
}
|
||||
|
||||
test_issue_permissions_declared() {
|
||||
echo "Running test_issue_permissions_declared"
|
||||
|
||||
local permissions
|
||||
permissions="$(ruby -e 'require "yaml"; workflow = YAML.load_file(ARGV[0]); puts workflow.fetch("permissions").fetch("issues")' "$WORKFLOW")"
|
||||
if [[ "$permissions" != "write" ]]; then
|
||||
fail "prepare-release workflow should request issues: write permission"
|
||||
fi
|
||||
|
||||
pass "test_issue_permissions_declared"
|
||||
}
|
||||
|
||||
test_issue_sync_uses_targeted_search() {
|
||||
echo "Running test_issue_sync_uses_targeted_search"
|
||||
|
||||
expect_file_contains "$WORKFLOW" "search.issuesAndPullRequests" "issue sync should use targeted issue search"
|
||||
expect_file_contains "$WORKFLOW" "is:issue in:body" "issue sync should search by marker in the body"
|
||||
if grep -Fq "issues.listForRepo" "$WORKFLOW"; then
|
||||
fail "issue sync should not paginate every repo issue"
|
||||
fi
|
||||
|
||||
pass "test_issue_sync_uses_targeted_search"
|
||||
}
|
||||
|
||||
test_deprecation_scan_uses_java_scanner() {
|
||||
echo "Running test_deprecation_scan_uses_java_scanner"
|
||||
|
||||
expect_file_contains "$WORKFLOW" "exec:java" "deprecation scan should run through Maven exec"
|
||||
expect_file_contains "$WORKFLOW" "-pl ta4j-examples -am -DskipTests install" \
|
||||
"deprecation scan should install reactor dependencies before execution"
|
||||
expect_file_contains "$WORKFLOW" "-pl ta4j-examples exec:java" \
|
||||
"deprecation scan should run exec:java only on ta4j-examples"
|
||||
expect_file_contains "$WORKFLOW" "ta4jexamples.doc.RemovalReadyDeprecationScanner" \
|
||||
"deprecation scan should invoke the Java scanner"
|
||||
if grep -Fq "scan-removal-ready-deprecations.py" "$WORKFLOW"; then
|
||||
fail "deprecation scan should not depend on the removed Python scanner"
|
||||
fi
|
||||
|
||||
pass "test_deprecation_scan_uses_java_scanner"
|
||||
}
|
||||
|
||||
test_prepare_release_sets_up_jdk25_before_maven() {
|
||||
echo "Running test_prepare_release_sets_up_jdk25_before_maven"
|
||||
|
||||
local setup_line
|
||||
local read_version_line
|
||||
local gate_line
|
||||
setup_line="$(line_of "Set up JDK 25")"
|
||||
read_version_line="$(line_of "Read current version from POM")"
|
||||
gate_line="$(line_of "Gate release-ready deprecations")"
|
||||
if (( setup_line >= read_version_line )); then
|
||||
fail "prepare-release should configure JDK 25 before reading the Maven project version"
|
||||
fi
|
||||
if (( setup_line >= gate_line )); then
|
||||
fail "prepare-release should configure JDK 25 before the deprecation release gate"
|
||||
fi
|
||||
|
||||
expect_file_contains "$WORKFLOW" "uses: actions/setup-java@v5" \
|
||||
"prepare-release should use the standard setup-java action"
|
||||
expect_file_contains "$WORKFLOW" "distribution: temurin" \
|
||||
"prepare-release should use the same Temurin distribution as other workflows"
|
||||
expect_file_contains "$WORKFLOW" "java-version: 25" \
|
||||
"prepare-release should satisfy the Maven enforcer Java version"
|
||||
expect_file_contains "$WORKFLOW" "cache: maven" \
|
||||
"prepare-release should enable Maven cache for Java setup"
|
||||
|
||||
pass "test_prepare_release_sets_up_jdk25_before_maven"
|
||||
}
|
||||
|
||||
test_release_gate_runs_before_next_snapshot() {
|
||||
echo "Running test_release_gate_runs_before_next_snapshot"
|
||||
|
||||
local gate_line
|
||||
local next_snapshot_line
|
||||
gate_line="$(line_of "Gate release-ready deprecations")"
|
||||
next_snapshot_line="$(line_of "Set Maven version to next snapshot")"
|
||||
if (( gate_line >= next_snapshot_line )); then
|
||||
fail "release deprecation gate should run before next snapshot version is applied"
|
||||
fi
|
||||
|
||||
expect_file_contains "$WORKFLOW" "--target-removal-version \${RELEASE_VERSION}" \
|
||||
"release gate should scan against the release version"
|
||||
expect_file_contains "$WORKFLOW" "--include-overdue --fail-on-due" \
|
||||
"release gate should include overdue removals and fail on due removals"
|
||||
expect_file_contains "$WORKFLOW" "release-ready-deprecations-\${{ steps.versions.outputs.release }}" \
|
||||
"release gate report should be uploaded as its own artifact"
|
||||
|
||||
pass "test_release_gate_runs_before_next_snapshot"
|
||||
}
|
||||
|
||||
test_next_snapshot_issue_sync_runs_after_snapshot_commit() {
|
||||
echo "Running test_next_snapshot_issue_sync_runs_after_snapshot_commit"
|
||||
|
||||
local snapshot_commit_line
|
||||
local scan_line
|
||||
local sync_line
|
||||
local push_line
|
||||
snapshot_commit_line="$(line_of "Commit next snapshot version")"
|
||||
scan_line="$(line_of "Scan removal-ready deprecations")"
|
||||
sync_line="$(line_of "Create or update removal-ready deprecation issues")"
|
||||
push_line="$(line_of "Push release branch")"
|
||||
if (( scan_line <= snapshot_commit_line )); then
|
||||
fail "next-snapshot deprecation scan should run after the next snapshot commit"
|
||||
fi
|
||||
if (( sync_line <= scan_line )); then
|
||||
fail "deprecation issue sync should run after the next-snapshot scan"
|
||||
fi
|
||||
if (( sync_line >= push_line )); then
|
||||
fail "deprecation issue sync should finish before the release branch is pushed"
|
||||
fi
|
||||
expect_file_contains "$WORKFLOW" "NEXT_REMOVAL_VERSION=\"\${NEXT_VERSION%-SNAPSHOT}\"" \
|
||||
"next-snapshot scan should derive the planned removal target from nextVersion"
|
||||
expect_file_contains "$WORKFLOW" "--target-removal-version \${NEXT_REMOVAL_VERSION}" \
|
||||
"next-snapshot scan should not rely on a dry-run POM mutation"
|
||||
expect_file_contains "$WORKFLOW" "--target-removal-version \${NEXT_REMOVAL_VERSION} --include-overdue" \
|
||||
"next-snapshot scan should include overdue removal versions when release versions jump"
|
||||
|
||||
pass "test_next_snapshot_issue_sync_runs_after_snapshot_commit"
|
||||
}
|
||||
|
||||
test_release_audit_includes_deprecation_counts() {
|
||||
echo "Running test_release_audit_includes_deprecation_counts"
|
||||
|
||||
expect_file_contains "$WORKFLOW" "\"removalIssuePlans\"" \
|
||||
"release audit JSON should include removal issue plan counts"
|
||||
expect_file_contains "$WORKFLOW" "\"removalIssuesCreated\"" \
|
||||
"release audit JSON should include created removal issue counts"
|
||||
expect_file_contains "$WORKFLOW" "\"removalIssuesClosedStale\"" \
|
||||
"release audit JSON should include stale issue closure counts"
|
||||
expect_file_contains "$WORKFLOW" "removal-ready deprecation issue plans" \
|
||||
"workflow summary should include removal-ready deprecation plan counts"
|
||||
expect_file_contains "$WORKFLOW" "prepare-release-audit-\${{ github.run_id }}" \
|
||||
"prepare-release audit artifact should still be uploaded"
|
||||
|
||||
pass "test_release_audit_includes_deprecation_counts"
|
||||
}
|
||||
|
||||
test_issue_sync_reconciles_stale_managed_issues() {
|
||||
echo "Running test_issue_sync_reconciles_stale_managed_issues"
|
||||
|
||||
expect_file_contains "$WORKFLOW" "ta4j:deprecation-removal version=\${removalVersion}" \
|
||||
"issue sync should search managed deprecation issues by removal version"
|
||||
expect_file_contains "$WORKFLOW" "closedStaleCount" \
|
||||
"issue sync should report stale managed issue closures"
|
||||
expect_file_contains "$WORKFLOW" "state_reason: \"completed\"" \
|
||||
"stale managed issues should be closed as completed"
|
||||
expect_file_contains "$WORKFLOW" "closed_stale_count" \
|
||||
"closed stale issue count should be exposed as an output"
|
||||
|
||||
pass "test_issue_sync_reconciles_stale_managed_issues"
|
||||
}
|
||||
|
||||
test_deprecation_issue_sync_does_not_add_metadata_to_cleanup_issues() {
|
||||
echo "Running test_deprecation_issue_sync_does_not_add_metadata_to_cleanup_issues"
|
||||
|
||||
local sync_section
|
||||
sync_section="$(workflow_section "Create or update removal-ready deprecation issues" "Upload removal-ready deprecation report")"
|
||||
if grep -Fq "labels:" <<<"$sync_section"; then
|
||||
fail "generated deprecation cleanup issues should not receive labels"
|
||||
fi
|
||||
if grep -Fq "assignees:" <<<"$sync_section"; then
|
||||
fail "generated deprecation cleanup issues should not receive assignees"
|
||||
fi
|
||||
|
||||
pass "test_deprecation_issue_sync_does_not_add_metadata_to_cleanup_issues"
|
||||
}
|
||||
|
||||
test_deprecation_scans_run_during_dry_runs() {
|
||||
echo "Running test_deprecation_scans_run_during_dry_runs"
|
||||
|
||||
local gate_section
|
||||
local scan_section
|
||||
local issue_section
|
||||
gate_section="$(workflow_section "Gate release-ready deprecations" "Upload release-ready deprecation gate report")"
|
||||
scan_section="$(workflow_section "Scan removal-ready deprecations" "Create or update removal-ready deprecation issues")"
|
||||
issue_section="$(workflow_section "Create or update removal-ready deprecation issues" "Upload removal-ready deprecation report")"
|
||||
if grep -Fq "if: steps.dry_run.outputs.dryRun != 'true'" <<<"$gate_section"; then
|
||||
fail "release deprecation gate should run during dry-run"
|
||||
fi
|
||||
if grep -Fq "if: steps.dry_run.outputs.dryRun != 'true'" <<<"$scan_section"; then
|
||||
fail "next-snapshot deprecation scan should run during dry-run"
|
||||
fi
|
||||
expect_file_contains "$WORKFLOW" "scan_status=\$scan_status" \
|
||||
"release gate should expose scan status for delayed dry-run failure"
|
||||
expect_file_contains "$WORKFLOW" "if: steps.dry_run.outputs.dryRun == 'true' && steps.deprecation_release_gate.outputs.scan_status != '0'" \
|
||||
"dry-run should fail after report uploads when release-ready deprecations are found"
|
||||
if ! grep -Fq "if: steps.dry_run.outputs.dryRun != 'true'" <<<"$issue_section"; then
|
||||
fail "deprecation issue sync should skip dry-run mutations"
|
||||
fi
|
||||
|
||||
pass "test_deprecation_scans_run_during_dry_runs"
|
||||
}
|
||||
|
||||
test_issue_permissions_declared
|
||||
test_issue_sync_uses_targeted_search
|
||||
test_deprecation_scan_uses_java_scanner
|
||||
test_prepare_release_sets_up_jdk25_before_maven
|
||||
test_release_gate_runs_before_next_snapshot
|
||||
test_next_snapshot_issue_sync_runs_after_snapshot_commit
|
||||
test_release_audit_includes_deprecation_counts
|
||||
test_issue_sync_reconciles_stale_managed_issues
|
||||
test_deprecation_issue_sync_does_not_add_metadata_to_cleanup_issues
|
||||
test_deprecation_scans_run_during_dry_runs
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
POM="$ROOT/pom.xml"
|
||||
CORE_POM="$ROOT/ta4j-core/pom.xml"
|
||||
EXAMPLES_POM="$ROOT/ta4j-examples/pom.xml"
|
||||
WORKFLOW="$ROOT/.github/workflows/test.yml"
|
||||
|
||||
fail() { echo "[FAIL] $1" >&2; exit 1; }
|
||||
pass() { echo "[PASS] $1"; }
|
||||
|
||||
expect_file_contains() {
|
||||
local file="$1"
|
||||
local needle="$2"
|
||||
local msg="$3"
|
||||
if ! grep -Fq -- "$needle" "$file"; then
|
||||
fail "$msg (missing: '$needle')"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_file_matches() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
local msg="$3"
|
||||
if ! grep -Eq -- "$pattern" "$file"; then
|
||||
fail "$msg (pattern: '$pattern')"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_file_not_contains() {
|
||||
local file="$1"
|
||||
local needle="$2"
|
||||
local msg="$3"
|
||||
if grep -Fq -- "$needle" "$file"; then
|
||||
fail "$msg (unexpected: '$needle')"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_execution_contains() {
|
||||
local file="$1"
|
||||
local execution_id="$2"
|
||||
local needle="$3"
|
||||
local msg="$4"
|
||||
local block
|
||||
block="$(awk -v id="$execution_id" '
|
||||
/<execution>/ { in_execution=1; block=$0 ORS; next }
|
||||
in_execution { block = block $0 ORS }
|
||||
/<\/execution>/ {
|
||||
if (block ~ "<id>" id "</id>") {
|
||||
print block
|
||||
found=1
|
||||
exit
|
||||
}
|
||||
in_execution=0
|
||||
block=""
|
||||
}
|
||||
END {
|
||||
if (!found) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "$file")" || fail "$msg (execution not found: '$execution_id')"
|
||||
|
||||
if ! grep -Fq -- "$needle" <<<"$block"; then
|
||||
fail "$msg (missing: '$needle' in execution '$execution_id')"
|
||||
fi
|
||||
}
|
||||
|
||||
test_parent_declares_advisory_quality_defaults() {
|
||||
echo "Running test_parent_declares_advisory_quality_defaults"
|
||||
|
||||
expect_file_matches "$POM" "<spotbugs.version>[0-9]+(\\.[0-9]+)+</spotbugs.version>" "parent pom should pin SpotBugs with an explicit version"
|
||||
expect_file_matches "$POM" "<jacoco.version>[0-9]+(\\.[0-9]+)+</jacoco.version>" "parent pom should pin JaCoCo with an explicit version"
|
||||
expect_file_contains "$POM" "<ta4j.jacoco.line.minimum>0.80</ta4j.jacoco.line.minimum>" "line coverage threshold should be declared"
|
||||
expect_file_contains "$POM" "<ta4j.jacoco.branch.minimum>0.80</ta4j.jacoco.branch.minimum>" "branch coverage threshold should be declared"
|
||||
expect_file_not_contains "$POM" "<ta4j.spotbugs.failOnError>" "SpotBugs advisory mode should not rely on a shared property"
|
||||
expect_file_not_contains "$POM" "<ta4j.jacoco.haltOnFailure>" "JaCoCo advisory mode should not rely on a shared property"
|
||||
|
||||
pass "test_parent_declares_advisory_quality_defaults"
|
||||
}
|
||||
|
||||
test_parent_manages_quality_plugins_for_verify() {
|
||||
echo "Running test_parent_manages_quality_plugins_for_verify"
|
||||
|
||||
expect_file_contains "$POM" "<artifactId>spotbugs-maven-plugin</artifactId>" "parent pom should manage SpotBugs"
|
||||
expect_file_contains "$POM" "<artifactId>jacoco-maven-plugin</artifactId>" "parent pom should manage JaCoCo"
|
||||
expect_file_contains "$POM" "<artifactId>exec-maven-plugin</artifactId>" "parent pom should skip root exec:java runs"
|
||||
expect_file_contains "$POM" "<skip>true</skip>" "parent pom should skip exec:java on the aggregator"
|
||||
expect_file_contains "$POM" "<quiet>true</quiet>" "SpotBugs should stay compact in verify logs"
|
||||
expect_file_contains "$POM" "@{argLine}" "Surefire should late-bind the JaCoCo agent argLine"
|
||||
expect_execution_contains "$POM" "spotbugs-check" "<phase>verify</phase>" "SpotBugs should stay wired into verify"
|
||||
expect_execution_contains "$POM" "spotbugs-check" "<failOnError>false</failOnError>" "SpotBugs should stay advisory only for the verify-bound execution"
|
||||
expect_execution_contains "$POM" "jacoco-check" "<phase>verify</phase>" "JaCoCo should stay wired into verify"
|
||||
expect_execution_contains "$POM" "jacoco-check" "<haltOnFailure>false</haltOnFailure>" "JaCoCo should stay advisory only for the verify-bound execution"
|
||||
|
||||
pass "test_parent_manages_quality_plugins_for_verify"
|
||||
}
|
||||
|
||||
test_modules_opt_in_to_managed_quality_plugins() {
|
||||
echo "Running test_modules_opt_in_to_managed_quality_plugins"
|
||||
|
||||
expect_file_contains "$CORE_POM" "<artifactId>spotbugs-maven-plugin</artifactId>" "ta4j-core should opt into SpotBugs"
|
||||
expect_file_contains "$CORE_POM" "<artifactId>jacoco-maven-plugin</artifactId>" "ta4j-core should opt into JaCoCo"
|
||||
expect_file_contains "$EXAMPLES_POM" "<artifactId>spotbugs-maven-plugin</artifactId>" "ta4j-examples should opt into SpotBugs"
|
||||
expect_file_contains "$EXAMPLES_POM" "<artifactId>jacoco-maven-plugin</artifactId>" "ta4j-examples should opt into JaCoCo"
|
||||
expect_file_contains "$EXAMPLES_POM" "<exec.mainClass>ta4jexamples.Quickstart</exec.mainClass>" "ta4j-examples should default exec:java to Quickstart"
|
||||
expect_file_contains "$EXAMPLES_POM" "<mainClass>\${exec.mainClass}</mainClass>" "ta4j-examples should allow exec:java main-class overrides"
|
||||
|
||||
pass "test_modules_opt_in_to_managed_quality_plugins"
|
||||
}
|
||||
|
||||
test_ci_runs_verify_for_both_jobs() {
|
||||
echo "Running test_ci_runs_verify_for_both_jobs"
|
||||
|
||||
expect_file_contains "$WORKFLOW" "run: xvfb-run mvn -B verify" "default CI job should run verify"
|
||||
expect_file_contains "$WORKFLOW" "run: xvfb-run mvn -B verify -Dta4j.excludedTestTags=analysis-demo,elliott-macro-cycle-replay" "non-demo CI job should run verify"
|
||||
|
||||
pass "test_ci_runs_verify_for_both_jobs"
|
||||
}
|
||||
|
||||
test_docs_point_to_real_maven_commands() {
|
||||
echo "Running test_docs_point_to_real_maven_commands"
|
||||
|
||||
expect_file_contains "$ROOT/README.md" "Run \`mvn verify\` before opening or updating a pull request." "README should point contributors at the real verify command"
|
||||
expect_file_contains "$ROOT/README.md" "mvn -pl ta4j-core -am spotbugs:check" "README should document the standalone SpotBugs loop"
|
||||
expect_file_contains "$ROOT/README.md" "mvn -pl ta4j-core -am test jacoco:report jacoco:check" "README should document the standalone JaCoCo gate"
|
||||
expect_file_contains "$ROOT/README.md" "mvn -pl ta4j-core -am -Dtest=BarSeriesManagerTest -Dsurefire.failIfNoSpecifiedTests=false test jacoco:report" "README should document a focused JaCoCo report-only loop"
|
||||
expect_file_contains "$ROOT/README.md" "- [Build commands: Maven](#build-commands-maven)" "README table of contents should link to the renamed build section"
|
||||
expect_file_contains "$ROOT/README.md" "mvn -pl ta4j-examples exec:java -Dexec.mainClass=ta4jexamples.backtesting.TradingRecordParityBacktest" "README should demonstrate overriding exec:java with a non-default example"
|
||||
expect_file_contains "$ROOT/.github/CONTRIBUTING.md" "**Run this before opening or updating a PR:** \`mvn -B verify\`" "contributing guide should use verify as the canonical PR command"
|
||||
expect_file_contains "$ROOT/.github/CONTRIBUTING.md" "mvn -pl ta4j-core -am spotbugs:check" "contributing guide should document the standalone SpotBugs loop"
|
||||
expect_file_contains "$ROOT/.github/CONTRIBUTING.md" "mvn -pl ta4j-core -am test jacoco:report jacoco:check" "contributing guide should document the standalone JaCoCo gate"
|
||||
expect_file_contains "$ROOT/.github/CONTRIBUTING.md" "mvn -B license:format formatter:format" "contributing guide should keep the formatter and license fix command"
|
||||
expect_file_not_contains "$ROOT/README.md" "./mvnw" "README should not advertise a missing Maven Wrapper"
|
||||
|
||||
pass "test_docs_point_to_real_maven_commands"
|
||||
}
|
||||
|
||||
test_parent_declares_advisory_quality_defaults
|
||||
test_parent_manages_quality_plugins_for_verify
|
||||
test_modules_opt_in_to_managed_quality_plugins
|
||||
test_ci_runs_verify_for_both_jobs
|
||||
test_docs_point_to_real_maven_commands
|
||||
|
||||
echo
|
||||
echo "All quality scan config tests passed."
|
||||
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# =============================================================================
|
||||
# test_release_helpers.sh
|
||||
#
|
||||
# Validates release workflow helper behavior used by GitHub Actions.
|
||||
# =============================================================================
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$ROOT/scripts/release/release_helpers.py"
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${TMP:-}" && -d "$TMP" ]]; then
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() { echo "[FAIL] $1" >&2; exit 1; }
|
||||
pass() { echo "[PASS] $1"; }
|
||||
|
||||
run_test() {
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/release-helpers.XXXXXX")"
|
||||
pushd "$TMP" >/dev/null || exit 1
|
||||
}
|
||||
|
||||
finish_test() {
|
||||
popd >/dev/null || exit 1
|
||||
rm -rf "$TMP"
|
||||
}
|
||||
|
||||
expect_file_contains() {
|
||||
local file="$1"
|
||||
local needle="$2"
|
||||
local msg="$3"
|
||||
if ! grep -Fq -- "$needle" "$file"; then
|
||||
fail "$msg (missing: '$needle')"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_json_value() {
|
||||
local file="$1"
|
||||
local filter="$2"
|
||||
local expected="$3"
|
||||
local actual
|
||||
actual="$($PYTHON - "$file" "$filter" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
path, key = sys.argv[1:3]
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
value = data
|
||||
for part in key.split("."):
|
||||
value = value[part]
|
||||
if isinstance(value, bool):
|
||||
print("true" if value else "false")
|
||||
else:
|
||||
print(value)
|
||||
PY
|
||||
)"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
fail "expected ${filter}=${expected}, got ${actual:-<missing>}"
|
||||
fi
|
||||
}
|
||||
|
||||
write_catalog_fixture() {
|
||||
cat > catalog.json <<'EOF'
|
||||
[
|
||||
{
|
||||
"id": "openai/gpt-4.1",
|
||||
"name": "OpenAI GPT-4.1",
|
||||
"publisher": "OpenAI",
|
||||
"summary": "Large-context model",
|
||||
"rate_limit_tier": "high",
|
||||
"limits": {
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 32768
|
||||
},
|
||||
"html_url": "https://github.com/marketplace/models/azure-openai/gpt-4-1"
|
||||
}
|
||||
]
|
||||
EOF
|
||||
}
|
||||
|
||||
test_catalog_preflight_accepts_configured_model() {
|
||||
echo "Running test_catalog_preflight_accepts_configured_model"
|
||||
run_test
|
||||
write_catalog_fixture
|
||||
|
||||
GITHUB_OUTPUT=outputs.txt "$PYTHON" "$SCRIPT" catalog-preflight \
|
||||
--model openai/gpt-4.1 \
|
||||
--catalog-file catalog.json \
|
||||
--output release-ai-model.json
|
||||
|
||||
expect_json_value release-ai-model.json id openai/gpt-4.1
|
||||
expect_json_value release-ai-model.json max_input_tokens 1048576
|
||||
expect_file_contains outputs.txt "model_id=openai/gpt-4.1" "catalog preflight should emit model id"
|
||||
|
||||
finish_test
|
||||
pass "test_catalog_preflight_accepts_configured_model"
|
||||
}
|
||||
|
||||
test_catalog_preflight_rejects_missing_model() {
|
||||
echo "Running test_catalog_preflight_rejects_missing_model"
|
||||
run_test
|
||||
write_catalog_fixture
|
||||
|
||||
if "$PYTHON" "$SCRIPT" catalog-preflight \
|
||||
--model openai/missing \
|
||||
--catalog-file catalog.json \
|
||||
--output release-ai-model.json >preflight.log 2>&1; then
|
||||
fail "catalog preflight should reject an unavailable model"
|
||||
fi
|
||||
expect_file_contains preflight.log "openai/missing" "failure should name missing model"
|
||||
|
||||
finish_test
|
||||
pass "test_catalog_preflight_rejects_missing_model"
|
||||
}
|
||||
|
||||
test_parse_decision_normalizes_major_and_invalid_json() {
|
||||
echo "Running test_parse_decision_normalizes_major_and_invalid_json"
|
||||
run_test
|
||||
|
||||
cat > ai-content.txt <<'EOF'
|
||||
```json
|
||||
{"should_release":true,"bump":"major","confidence":0.91,"reason":"Breaking but pre-1.0 change","evidence":["public API changed"]}
|
||||
```
|
||||
EOF
|
||||
"$PYTHON" "$SCRIPT" parse-decision --raw-file ai-content.txt --output decision.json --github-output outputs.txt
|
||||
expect_json_value decision.json should_release true
|
||||
expect_json_value decision.json bump minor
|
||||
expect_file_contains outputs.txt "bump=minor" "major bumps should be downgraded in outputs"
|
||||
|
||||
cat > ai-content.txt <<'EOF'
|
||||
{"should_release":"false","bump":"minor","confidence":0.7,"reason":"No release needed"}
|
||||
EOF
|
||||
"$PYTHON" "$SCRIPT" parse-decision --raw-file ai-content.txt --output string-false.json
|
||||
expect_json_value string-false.json should_release false
|
||||
expect_json_value string-false.json bump patch
|
||||
|
||||
cat > ai-content.txt <<'EOF'
|
||||
{"should_release":"1","bump":"patch","confidence":0.7,"reason":"Release needed"}
|
||||
EOF
|
||||
"$PYTHON" "$SCRIPT" parse-decision --raw-file ai-content.txt --output string-true.json
|
||||
expect_json_value string-true.json should_release true
|
||||
|
||||
cat > ai-content.txt <<'EOF'
|
||||
{"should_release":"maybe","bump":"minor","confidence":0.7,"reason":"Ambiguous response"}
|
||||
EOF
|
||||
"$PYTHON" "$SCRIPT" parse-decision --raw-file ai-content.txt --output invalid-flag.json
|
||||
expect_json_value invalid-flag.json should_release false
|
||||
expect_json_value invalid-flag.json bump patch
|
||||
expect_file_contains invalid-flag.json "invalid should_release 'maybe'" "invalid flag should be called out"
|
||||
|
||||
printf 'not-json' > ai-content.txt
|
||||
"$PYTHON" "$SCRIPT" parse-decision --raw-file ai-content.txt --output invalid.json
|
||||
expect_json_value invalid.json should_release false
|
||||
expect_json_value invalid.json bump patch
|
||||
expect_json_value invalid.json warning "Invalid AI JSON"
|
||||
|
||||
finish_test
|
||||
pass "test_parse_decision_normalizes_major_and_invalid_json"
|
||||
}
|
||||
|
||||
test_build_dossier_groups_and_truncates_diff() {
|
||||
echo "Running test_build_dossier_groups_and_truncates_diff"
|
||||
run_test
|
||||
|
||||
git init -q -b master
|
||||
git config user.name "Test User"
|
||||
git config user.email "test@example.com"
|
||||
mkdir -p ta4j-core/src/main/java/org/ta4j/core scripts
|
||||
cat > CHANGELOG.md <<'EOF'
|
||||
## Unreleased
|
||||
|
||||
- Added release helper coverage.
|
||||
EOF
|
||||
cat > pom.xml <<'EOF'
|
||||
<project><version>1.0.0-SNAPSHOT</version></project>
|
||||
EOF
|
||||
cat > ta4j-core/src/main/java/org/ta4j/core/Fixture.java <<'EOF'
|
||||
package org.ta4j.core;
|
||||
|
||||
public class Fixture {
|
||||
}
|
||||
EOF
|
||||
git add .
|
||||
git commit -q -m "Initial"
|
||||
git tag -a 1.0.0 -m "Release 1.0.0"
|
||||
|
||||
cat > ta4j-core/src/main/java/org/ta4j/core/Fixture.java <<'EOF'
|
||||
package org.ta4j.core;
|
||||
|
||||
/**
|
||||
* Fixture API.
|
||||
*
|
||||
* @since 1.0.1
|
||||
*/
|
||||
public class Fixture {
|
||||
public String value() {
|
||||
return "release-helper-dossier-with-enough-content-to-trigger-truncation";
|
||||
}
|
||||
}
|
||||
EOF
|
||||
git add .
|
||||
git commit -q -m "Add fixture API"
|
||||
|
||||
"$PYTHON" "$SCRIPT" build-dossier \
|
||||
--last-tag 1.0.0 \
|
||||
--current-version 1.0.1-SNAPSHOT \
|
||||
--pom-base 1.0.1 \
|
||||
--max-diff-chars 120 \
|
||||
--output release-dossier.md \
|
||||
--audit-output release-audit.json
|
||||
|
||||
expect_file_contains release-dossier.md "production code" "dossier should group production code"
|
||||
expect_file_contains release-dossier.md "Public API Signals" "dossier should include API signal section"
|
||||
expect_file_contains release-dossier.md "[TRUNCATED" "dossier should make diff truncation explicit"
|
||||
expect_json_value release-audit.json selected_diff_truncated true
|
||||
|
||||
finish_test
|
||||
pass "test_build_dossier_groups_and_truncates_diff"
|
||||
}
|
||||
|
||||
test_artifact_manifest_validates_expected_release_jars() {
|
||||
echo "Running test_artifact_manifest_validates_expected_release_jars"
|
||||
run_test
|
||||
|
||||
version=1.2.3
|
||||
for file in \
|
||||
"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"; do
|
||||
mkdir -p "$(dirname "$file")"
|
||||
: > "$file"
|
||||
done
|
||||
|
||||
"$PYTHON" "$SCRIPT" artifact-manifest --version "$version" --output artifact-manifest.txt --strict
|
||||
expect_file_contains artifact-manifest.txt "Missing release artifacts:" "manifest should include missing section"
|
||||
expect_file_contains artifact-manifest.txt "- (none)" "manifest should report no missing artifacts"
|
||||
|
||||
: > "ta4j-core/target/unexpected.jar"
|
||||
if "$PYTHON" "$SCRIPT" artifact-manifest --version "$version" --output artifact-manifest.txt --strict >manifest.log 2>&1; then
|
||||
fail "strict artifact manifest should reject unexpected jars"
|
||||
fi
|
||||
expect_file_contains manifest.log "Unexpected target jars" "strict failure should name unexpected jars"
|
||||
|
||||
finish_test
|
||||
pass "test_artifact_manifest_validates_expected_release_jars"
|
||||
}
|
||||
|
||||
test_javadoc_warning_baseline_rejects_new_warnings() {
|
||||
echo "Running test_javadoc_warning_baseline_rejects_new_warnings"
|
||||
run_test
|
||||
|
||||
cat > baseline.txt <<'EOF'
|
||||
ta4j-core/src/main/java/org/ta4j/core/Foo.java: warning: no @param for value
|
||||
EOF
|
||||
cat > release.log <<'EOF'
|
||||
[WARNING] /home/runner/work/ta4j/ta4j/ta4j-core/src/main/java/org/ta4j/core/Foo.java: warning: no @param for value
|
||||
[WARNING] /home/runner/work/ta4j/ta4j/ta4j-core/src/test/java/org/ta4j/core/BaseTradingRecordTest.java:[61,15] recordFill(org.ta4j.core.Trade) has been deprecated
|
||||
EOF
|
||||
|
||||
"$PYTHON" "$SCRIPT" javadoc-warnings \
|
||||
--baseline baseline.txt \
|
||||
--output javadoc-warnings.txt \
|
||||
--github-output outputs.txt \
|
||||
--fail-on-new \
|
||||
release.log
|
||||
expect_file_contains outputs.txt "javadoc_warning_new_count=0" "baseline match should not report new warnings"
|
||||
expect_file_contains javadoc-warnings.txt "current_count=1" "compiler warnings should not count as Javadoc warnings"
|
||||
|
||||
cat >> release.log <<'EOF'
|
||||
[WARNING] /home/runner/work/ta4j/ta4j/ta4j-examples/src/main/java/ta4jexamples/FooExample.java: warning: no @return
|
||||
EOF
|
||||
if "$PYTHON" "$SCRIPT" javadoc-warnings \
|
||||
--baseline baseline.txt \
|
||||
--output javadoc-warnings.txt \
|
||||
--fail-on-new \
|
||||
release.log >javadoc.log 2>&1; then
|
||||
fail "new Javadoc warning should fail the baseline check"
|
||||
fi
|
||||
expect_file_contains javadoc.log "New Javadoc warning" "baseline failure should name new warning"
|
||||
|
||||
finish_test
|
||||
pass "test_javadoc_warning_baseline_rejects_new_warnings"
|
||||
}
|
||||
|
||||
test_catalog_preflight_accepts_configured_model
|
||||
test_catalog_preflight_rejects_missing_model
|
||||
test_parse_decision_normalizes_major_and_invalid_json
|
||||
test_build_dossier_groups_and_truncates_diff
|
||||
test_artifact_manifest_validates_expected_release_jars
|
||||
test_javadoc_warning_baseline_rejects_new_warnings
|
||||
|
||||
echo
|
||||
echo "All release helper tests passed."
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
WORKFLOWS="$ROOT/.github/workflows"
|
||||
|
||||
fail() { echo "[FAIL] $1" >&2; exit 1; }
|
||||
pass() { echo "[PASS] $1"; }
|
||||
|
||||
expect_file_contains() {
|
||||
local file="$1"
|
||||
local needle="$2"
|
||||
local msg="$3"
|
||||
if ! grep -Fq -- "$needle" "$file"; then
|
||||
fail "$msg (missing: '$needle' in ${file#"$ROOT"/})"
|
||||
fi
|
||||
}
|
||||
|
||||
line_of() {
|
||||
local file="$1"
|
||||
local needle="$2"
|
||||
local line
|
||||
line="$(grep -nFm1 -- "$needle" "$file" || true)"
|
||||
line="${line%%:*}"
|
||||
if [[ -z "$line" ]]; then
|
||||
fail "missing expected workflow line '$needle' in ${file#"$ROOT"/}"
|
||||
fi
|
||||
printf '%s\n' "$line"
|
||||
}
|
||||
|
||||
input_section() {
|
||||
local file="$1"
|
||||
local input="$2"
|
||||
awk -v needle=" ${input}:" '
|
||||
index($0, needle) { active = 1 }
|
||||
active { print }
|
||||
active && index($0, "type: boolean") { exit }
|
||||
' "$file"
|
||||
}
|
||||
|
||||
workflow_section() {
|
||||
local file="$1"
|
||||
local start="$2"
|
||||
local end="$3"
|
||||
awk -v start="$start" -v end="$end" '
|
||||
index($0, start) { active = 1 }
|
||||
active { print }
|
||||
active && index($0, end) { exit }
|
||||
' "$file"
|
||||
}
|
||||
|
||||
expect_section_contains() {
|
||||
local section="$1"
|
||||
local needle="$2"
|
||||
local msg="$3"
|
||||
if ! grep -Fq -- "$needle" <<<"$section"; then
|
||||
fail "$msg (missing: '$needle')"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_no_github_script_injected_binding_redeclarations() {
|
||||
local workflow="$1"
|
||||
awk -v file="${workflow#"$ROOT"/}" '
|
||||
/uses: actions\/github-script@/ {
|
||||
pending_script = 1
|
||||
in_script = 0
|
||||
next
|
||||
}
|
||||
pending_script && /^[[:space:]]*script: \|/ {
|
||||
in_script = 1
|
||||
next
|
||||
}
|
||||
in_script && /^[[:space:]]{6}- name:/ {
|
||||
pending_script = 0
|
||||
in_script = 0
|
||||
}
|
||||
in_script && /^[[:space:]]*(const|let|var)[[:space:]]+(core|github|context|glob|io|exec)[[:space:]]*=/ {
|
||||
printf("[FAIL] %s redeclares actions/github-script injected binding at line %d: %s\n", file, NR, $0) > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
' "$workflow"
|
||||
}
|
||||
|
||||
test_maven_workflow_jobs_setup_jdk25_before_maven() {
|
||||
echo "Running test_maven_workflow_jobs_setup_jdk25_before_maven"
|
||||
|
||||
local workflow
|
||||
for workflow in "$WORKFLOWS"/*.yml; do
|
||||
if ! grep -Eq '(^|[[:space:]"({;])xvfb-run[[:space:]]+mvn[[:space:]-]|(^|[[:space:]"({;])mvn[[:space:]-]' "$workflow"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
awk -v file="${workflow#"$ROOT"/}" '
|
||||
/^jobs:/ { in_jobs = 1; next }
|
||||
in_jobs && /^ [A-Za-z0-9_-]+:$/ {
|
||||
job = $1
|
||||
sub(/:$/, "", job)
|
||||
setup = 0
|
||||
temurin = 0
|
||||
jdk25 = 0
|
||||
next
|
||||
}
|
||||
/^[^[:space:]]/ && $0 !~ /^jobs:/ {
|
||||
in_jobs = 0
|
||||
}
|
||||
/uses: actions\/setup-java@v5/ {
|
||||
setup = 1
|
||||
temurin = 0
|
||||
jdk25 = 0
|
||||
}
|
||||
setup && /distribution: temurin/ { temurin = 1 }
|
||||
setup && /java-version: 25/ { jdk25 = 1 }
|
||||
/^[[:space:]]*#/ { next }
|
||||
/(^|[[:space:]"({;])xvfb-run[[:space:]]+mvn[[:space:]-]|(^|[[:space:]"({;])mvn[[:space:]-]/ {
|
||||
if (!setup || !temurin || !jdk25) {
|
||||
printf("[FAIL] %s job %s runs Maven before Temurin JDK 25 setup at line %d\n", file, job ? job : "(unknown)", NR) > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "$workflow"
|
||||
done
|
||||
|
||||
pass "test_maven_workflow_jobs_setup_jdk25_before_maven"
|
||||
}
|
||||
|
||||
test_mutating_manual_workflows_default_to_dry_run() {
|
||||
echo "Running test_mutating_manual_workflows_default_to_dry_run"
|
||||
|
||||
local workflow
|
||||
for workflow in \
|
||||
release-scheduler.yml \
|
||||
prepare-release.yml \
|
||||
publish-release.yml \
|
||||
github-release.yml \
|
||||
snapshot.yml \
|
||||
release-health.yml; do
|
||||
local file="$WORKFLOWS/$workflow"
|
||||
local section
|
||||
section="$(input_section "$file" dryRun)"
|
||||
expect_section_contains "$section" "default: true" "${workflow} manual dryRun input should default true"
|
||||
expect_section_contains "$section" "type: boolean" "${workflow} manual dryRun input should be typed as boolean"
|
||||
done
|
||||
|
||||
pass "test_mutating_manual_workflows_default_to_dry_run"
|
||||
}
|
||||
|
||||
test_official_triggers_normalize_to_non_dry_run() {
|
||||
echo "Running test_official_triggers_normalize_to_non_dry_run"
|
||||
|
||||
expect_file_contains "$WORKFLOWS/release-scheduler.yml" 'if [ "$event" = "workflow_dispatch" ]; then' \
|
||||
"release scheduler should branch normalization by event"
|
||||
expect_file_contains "$WORKFLOWS/release-scheduler.yml" "raw=\"\${DRY_RUN_INPUT:-true}\"" \
|
||||
"release scheduler manual default should normalize to dry-run"
|
||||
expect_file_contains "$WORKFLOWS/release-scheduler.yml" "dry_run=false" \
|
||||
"release scheduler scheduled runs should normalize to non-dry-run"
|
||||
|
||||
expect_file_contains "$WORKFLOWS/publish-release.yml" 'if [ "${{ github.event_name }}" = "pull_request" ]; then' \
|
||||
"publish-release should normalize merged release PRs to non-dry-run"
|
||||
expect_file_contains "$WORKFLOWS/github-release.yml" 'if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then' \
|
||||
"github-release should keep tag pushes non-dry-run"
|
||||
expect_file_contains "$WORKFLOWS/snapshot.yml" 'if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then' \
|
||||
"snapshot should keep master pushes non-dry-run"
|
||||
expect_file_contains "$WORKFLOWS/release-health.yml" 'if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then' \
|
||||
"release-health should keep schedule/push/workflow-run triggers non-dry-run"
|
||||
|
||||
pass "test_official_triggers_normalize_to_non_dry_run"
|
||||
}
|
||||
|
||||
test_downstream_dispatches_explicitly_pass_dry_run() {
|
||||
echo "Running test_downstream_dispatches_explicitly_pass_dry_run"
|
||||
|
||||
local scheduler_dispatch
|
||||
scheduler_dispatch="$(workflow_section "$WORKFLOWS/release-scheduler.yml" 'workflow_id: "prepare-release.yml"' "dryRun")"
|
||||
expect_section_contains "$scheduler_dispatch" "dryRun" \
|
||||
"release scheduler dispatch should pass dryRun to prepare-release"
|
||||
|
||||
local prepare_dispatch
|
||||
prepare_dispatch="$(workflow_section "$WORKFLOWS/prepare-release.yml" 'workflow_id: "publish-release.yml"' "dryRun")"
|
||||
expect_section_contains "$prepare_dispatch" "dryRun" \
|
||||
"prepare-release dispatch should pass dryRun to publish-release"
|
||||
|
||||
local publish_dispatch
|
||||
publish_dispatch="$(workflow_section "$WORKFLOWS/publish-release.yml" 'workflow_id: "snapshot.yml"' 'dryRun: "false"')"
|
||||
expect_section_contains "$publish_dispatch" 'dryRun: "false"' \
|
||||
"publish-release should explicitly dispatch snapshot publication as non-dry-run"
|
||||
|
||||
pass "test_downstream_dispatches_explicitly_pass_dry_run"
|
||||
}
|
||||
|
||||
test_mutating_steps_remain_dry_run_gated() {
|
||||
echo "Running test_mutating_steps_remain_dry_run_gated"
|
||||
|
||||
expect_file_contains "$WORKFLOWS/release-scheduler.yml" "if: always() && needs.analyze.result != 'skipped' && needs.analyze.outputs.dryRun != 'true'" \
|
||||
"release scheduler discussion mutation should skip dry-run and skipped scheduled runs"
|
||||
|
||||
expect_file_contains "$WORKFLOWS/prepare-release.yml" "if: steps.dry_run.outputs.dryRun != 'true'" \
|
||||
"prepare-release mutations should be dry-run gated"
|
||||
expect_file_contains "$WORKFLOWS/prepare-release.yml" "Create or update removal-ready deprecation issues" \
|
||||
"prepare-release should still have managed issue sync"
|
||||
expect_file_contains "$WORKFLOWS/prepare-release.yml" "Create or update release PR" \
|
||||
"prepare-release should still have release PR mutation"
|
||||
|
||||
expect_file_contains "$WORKFLOWS/publish-release.yml" "Deploy Release to Maven Central" \
|
||||
"publish-release should still have Maven Central deployment"
|
||||
expect_file_contains "$WORKFLOWS/publish-release.yml" "if: steps.dry_run.outputs.dryRun != 'true'" \
|
||||
"publish-release mutations should be dry-run gated"
|
||||
expect_file_contains "$WORKFLOWS/publish-release.yml" "if: always() && steps.dry_run.outputs.dryRun != 'true'" \
|
||||
"publish-release discussion mutation should skip dry-run"
|
||||
|
||||
expect_file_contains "$WORKFLOWS/github-release.yml" "Create GitHub Release" \
|
||||
"github-release should still publish GitHub releases"
|
||||
expect_file_contains "$WORKFLOWS/github-release.yml" "if: steps.dry_run.outputs.dryRun != 'true'" \
|
||||
"github-release publication should skip dry-run"
|
||||
|
||||
expect_file_contains "$WORKFLOWS/snapshot.yml" "Deploy Snapshot" \
|
||||
"snapshot workflow should still publish snapshots"
|
||||
expect_file_contains "$WORKFLOWS/snapshot.yml" "if: steps.dry_run.outputs.dryRun != 'true'" \
|
||||
"snapshot deployment and publishing-secret steps should skip dry-run"
|
||||
|
||||
expect_file_contains "$WORKFLOWS/release-health.yml" "if: always() && steps.dry_run.outputs.dryRun != 'true'" \
|
||||
"release-health discussion mutation should skip dry-run"
|
||||
|
||||
pass "test_mutating_steps_remain_dry_run_gated"
|
||||
}
|
||||
|
||||
test_dry_run_summaries_and_audits_show_rerun_guidance() {
|
||||
echo "Running test_dry_run_summaries_and_audits_show_rerun_guidance"
|
||||
|
||||
expect_file_contains "$WORKFLOWS/release-scheduler.yml" "release-scheduler-mutation-plan.txt" \
|
||||
"release scheduler audit artifact should include the dry-run mutation plan"
|
||||
expect_file_contains "$WORKFLOWS/prepare-release.yml" '"mutationPlan"' \
|
||||
"prepare-release audit JSON should include mutation plan"
|
||||
expect_file_contains "$WORKFLOWS/publish-release.yml" '"mutationPlan"' \
|
||||
"publish-release audit JSON should include mutation plan"
|
||||
expect_file_contains "$WORKFLOWS/github-release.yml" '"mutationPlan"' \
|
||||
"github-release audit JSON should include mutation plan"
|
||||
expect_file_contains "$WORKFLOWS/snapshot.yml" '"snapshotVersion"' \
|
||||
"snapshot audit JSON should include computed snapshot version"
|
||||
expect_file_contains "$WORKFLOWS/snapshot.yml" '"mutationPlan"' \
|
||||
"snapshot audit JSON should include mutation plan"
|
||||
expect_file_contains "$WORKFLOWS/release-health.yml" '"mutationPlan"' \
|
||||
"release-health audit JSON should include mutation plan"
|
||||
expect_file_contains "$WORKFLOWS/prepare-release.yml" "rerun guidance" \
|
||||
"prepare-release summary should include rerun guidance"
|
||||
expect_file_contains "$WORKFLOWS/publish-release.yml" "rerun guidance" \
|
||||
"publish-release summary should include rerun guidance"
|
||||
expect_file_contains "$WORKFLOWS/snapshot.yml" "rerun guidance" \
|
||||
"snapshot summary should include rerun guidance"
|
||||
|
||||
pass "test_dry_run_summaries_and_audits_show_rerun_guidance"
|
||||
}
|
||||
|
||||
test_snapshot_and_health_manual_dry_runs_do_not_mutate() {
|
||||
echo "Running test_snapshot_and_health_manual_dry_runs_do_not_mutate"
|
||||
|
||||
local deploy_section
|
||||
deploy_section="$(workflow_section "$WORKFLOWS/snapshot.yml" "Deploy Snapshot" "Snapshot publication summary")"
|
||||
expect_section_contains "$deploy_section" "if: steps.dry_run.outputs.dryRun != 'true'" \
|
||||
"snapshot deployment should skip dry-run"
|
||||
|
||||
local health_post_section
|
||||
health_post_section="$(workflow_section "$WORKFLOWS/release-health.yml" "Post to Release Scheduler discussion" "with:")"
|
||||
expect_section_contains "$health_post_section" "if: always() && steps.dry_run.outputs.dryRun != 'true'" \
|
||||
"release-health discussion post should skip dry-run"
|
||||
|
||||
pass "test_snapshot_and_health_manual_dry_runs_do_not_mutate"
|
||||
}
|
||||
|
||||
test_line_of_reports_missing_needles_cleanly() {
|
||||
echo "Running test_line_of_reports_missing_needles_cleanly"
|
||||
|
||||
local tmp
|
||||
local output
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/release-workflow-safety.XXXXXX")"
|
||||
printf 'present\n' > "$tmp"
|
||||
|
||||
if output="$( (line_of "$tmp" "missing") 2>&1 )"; then
|
||||
rm -f "$tmp"
|
||||
fail "line_of should fail when the workflow line is missing"
|
||||
fi
|
||||
|
||||
rm -f "$tmp"
|
||||
expect_section_contains "$output" "missing expected workflow line 'missing'" \
|
||||
"line_of should surface the explicit missing-line failure message"
|
||||
|
||||
pass "test_line_of_reports_missing_needles_cleanly"
|
||||
}
|
||||
|
||||
test_publish_release_existing_tag_only_fails_real_runs() {
|
||||
echo "Running test_publish_release_existing_tag_only_fails_real_runs"
|
||||
|
||||
expect_file_contains "$WORKFLOWS/publish-release.yml" \
|
||||
"if: steps.check_tag.outputs.exists == 'true' && steps.dry_run.outputs.dryRun != 'true'" \
|
||||
"publish-release should fail existing tags only during real runs"
|
||||
expect_file_contains "$WORKFLOWS/publish-release.yml" "Report existing tag in dry-run mode" \
|
||||
"publish-release should keep a dry-run audit path for existing tags"
|
||||
expect_file_contains "$WORKFLOWS/publish-release.yml" "audit:existing_tag_dry_run" \
|
||||
"publish-release dry-run tag detection should emit an audit line"
|
||||
|
||||
pass "test_publish_release_existing_tag_only_fails_real_runs"
|
||||
}
|
||||
|
||||
test_github_release_preserves_workflow_support_checkout() {
|
||||
echo "Running test_github_release_preserves_workflow_support_checkout"
|
||||
|
||||
local full_checkout_line
|
||||
local support_checkout_line
|
||||
local manifest_line
|
||||
full_checkout_line="$(line_of "$WORKFLOWS/github-release.yml" "Checkout full history")"
|
||||
support_checkout_line="$(line_of "$WORKFLOWS/github-release.yml" "Checkout workflow support files")"
|
||||
manifest_line="$(line_of "$WORKFLOWS/github-release.yml" "workflow-support/scripts/release/release_helpers.py artifact-manifest")"
|
||||
|
||||
if (( support_checkout_line <= full_checkout_line )); then
|
||||
fail "github-release should checkout workflow support after the release tag checkout"
|
||||
fi
|
||||
if (( manifest_line <= support_checkout_line )); then
|
||||
fail "github-release should validate artifacts after workflow support checkout"
|
||||
fi
|
||||
expect_file_contains "$WORKFLOWS/github-release.yml" "path: workflow-support" \
|
||||
"github-release should keep support helpers outside the release tag checkout"
|
||||
|
||||
pass "test_github_release_preserves_workflow_support_checkout"
|
||||
}
|
||||
|
||||
test_github_script_blocks_do_not_redeclare_injected_bindings() {
|
||||
echo "Running test_github_script_blocks_do_not_redeclare_injected_bindings"
|
||||
|
||||
local workflow
|
||||
for workflow in "$WORKFLOWS"/*.yml; do
|
||||
assert_no_github_script_injected_binding_redeclarations "$workflow"
|
||||
done
|
||||
|
||||
pass "test_github_script_blocks_do_not_redeclare_injected_bindings"
|
||||
}
|
||||
|
||||
test_github_script_binding_scan_rejects_bad_fixture() {
|
||||
echo "Running test_github_script_binding_scan_rejects_bad_fixture"
|
||||
|
||||
local tmp
|
||||
local output
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/release-workflow-safety.XXXXXX")"
|
||||
cat > "$tmp" <<'YAML'
|
||||
name: Bad fixture
|
||||
jobs:
|
||||
bad:
|
||||
steps:
|
||||
- name: Bad GitHub Script
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const core = require("@actions/core");
|
||||
YAML
|
||||
|
||||
if output="$(assert_no_github_script_injected_binding_redeclarations "$tmp" 2>&1)"; then
|
||||
rm -f "$tmp"
|
||||
fail "github-script injected binding scan should fail on a core redeclaration"
|
||||
fi
|
||||
|
||||
rm -f "$tmp"
|
||||
expect_section_contains "$output" "redeclares actions/github-script injected binding" \
|
||||
"github-script injected binding scan should report the redeclaration"
|
||||
|
||||
pass "test_github_script_binding_scan_rejects_bad_fixture"
|
||||
}
|
||||
|
||||
test_maven_workflow_jobs_setup_jdk25_before_maven
|
||||
test_mutating_manual_workflows_default_to_dry_run
|
||||
test_official_triggers_normalize_to_non_dry_run
|
||||
test_downstream_dispatches_explicitly_pass_dry_run
|
||||
test_mutating_steps_remain_dry_run_gated
|
||||
test_dry_run_summaries_and_audits_show_rerun_guidance
|
||||
test_snapshot_and_health_manual_dry_runs_do_not_mutate
|
||||
test_line_of_reports_missing_needles_cleanly
|
||||
test_publish_release_existing_tag_only_fails_real_runs
|
||||
test_github_release_preserves_workflow_support_checkout
|
||||
test_github_script_blocks_do_not_redeclare_injected_bindings
|
||||
test_github_script_binding_scan_rejects_bad_fixture
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# =============================================================================
|
||||
# test_resolve_release_tags.sh
|
||||
#
|
||||
# Validates release tag baseline selection and first-parent diagnostics.
|
||||
# =============================================================================
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$ROOT/scripts/resolve-release-tags.sh"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${TMP:-}" && -d "$TMP" ]]; then
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() { echo "[FAIL] $1" >&2; exit 1; }
|
||||
pass() { echo "[PASS] $1"; }
|
||||
|
||||
expect_output_value() {
|
||||
local output="$1"
|
||||
local key="$2"
|
||||
local expected="$3"
|
||||
local actual=""
|
||||
|
||||
actual="$(printf '%s\n' "$output" | awk -F= -v key="$key" '$1 == key { print substr($0, length($1) + 2); exit }')"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
fail "expected ${key}=${expected}, got ${actual:-<missing>}"
|
||||
fi
|
||||
}
|
||||
|
||||
run_test() {
|
||||
TMP="$(mktemp -d)"
|
||||
mkdir -p "$TMP/scripts"
|
||||
cp "$SCRIPT" "$TMP/scripts/resolve-release-tags.sh"
|
||||
chmod +x "$TMP/scripts/resolve-release-tags.sh"
|
||||
|
||||
pushd "$TMP" >/dev/null || exit 1
|
||||
git init -q -b master
|
||||
git config user.name "Test User"
|
||||
git config user.email "test@example.com"
|
||||
}
|
||||
|
||||
finish_test() {
|
||||
popd >/dev/null || exit 1
|
||||
rm -rf "$TMP"
|
||||
}
|
||||
|
||||
commit_file() {
|
||||
local path="$1"
|
||||
local content="$2"
|
||||
local message="$3"
|
||||
local date="$4"
|
||||
|
||||
mkdir -p "$(dirname "$path")"
|
||||
printf '%s\n' "$content" > "$path"
|
||||
git add "$path"
|
||||
GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" git commit -q -m "$message"
|
||||
}
|
||||
|
||||
tag_release() {
|
||||
local tag="$1"
|
||||
local date="$2"
|
||||
|
||||
GIT_COMMITTER_DATE="$date" git tag -a "$tag" -m "Release $tag"
|
||||
}
|
||||
|
||||
merge_branch() {
|
||||
local branch="$1"
|
||||
local message="$2"
|
||||
local date="$3"
|
||||
|
||||
GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" git merge --no-ff -m "$message" "$branch" >/dev/null
|
||||
}
|
||||
|
||||
test_returns_none_when_no_release_tags_exist() {
|
||||
echo "Running test_returns_none_when_no_release_tags_exist"
|
||||
run_test
|
||||
|
||||
commit_file README.md "fixture" "Initial commit" "2024-01-01T00:00:00Z"
|
||||
|
||||
local out
|
||||
out="$(scripts/resolve-release-tags.sh HEAD)"
|
||||
|
||||
expect_output_value "$out" "latest_tag" "none"
|
||||
expect_output_value "$out" "last_reachable_tag" "none"
|
||||
expect_output_value "$out" "last_first_parent_tag" "none"
|
||||
expect_output_value "$out" "latest_tag_reachable" "n/a"
|
||||
|
||||
finish_test
|
||||
pass "test_returns_none_when_no_release_tags_exist"
|
||||
}
|
||||
|
||||
test_prefers_reachable_tag_for_release_baseline() {
|
||||
echo "Running test_prefers_reachable_tag_for_release_baseline"
|
||||
run_test
|
||||
|
||||
commit_file README.md "base" "Initial commit" "2024-01-01T00:00:00Z"
|
||||
tag_release "0.21.0" "2024-01-02T00:00:00Z"
|
||||
commit_file main.txt "after 0.21.0" "Master commit after 0.21.0" "2024-01-03T00:00:00Z"
|
||||
|
||||
git checkout -q -b release/0.22.4
|
||||
commit_file release.txt "release payload" "Release 0.22.4" "2024-01-04T00:00:00Z"
|
||||
tag_release "0.22.4" "2024-01-05T00:00:00Z"
|
||||
|
||||
git checkout -q master
|
||||
merge_branch "release/0.22.4" "Merge release/0.22.4" "2024-01-06T00:00:00Z"
|
||||
|
||||
local out
|
||||
out="$(scripts/resolve-release-tags.sh HEAD)"
|
||||
|
||||
expect_output_value "$out" "latest_tag" "0.22.4"
|
||||
expect_output_value "$out" "last_reachable_tag" "0.22.4"
|
||||
expect_output_value "$out" "last_first_parent_tag" "0.21.0"
|
||||
expect_output_value "$out" "latest_tag_reachable" "true"
|
||||
|
||||
finish_test
|
||||
pass "test_prefers_reachable_tag_for_release_baseline"
|
||||
}
|
||||
|
||||
test_reports_unreachable_latest_tag() {
|
||||
echo "Running test_reports_unreachable_latest_tag"
|
||||
run_test
|
||||
|
||||
commit_file README.md "base" "Initial commit" "2024-02-01T00:00:00Z"
|
||||
tag_release "0.22.3" "2024-02-02T00:00:00Z"
|
||||
commit_file main.txt "master work" "Master commit" "2024-02-03T00:00:00Z"
|
||||
|
||||
git checkout -q -b release/0.22.4
|
||||
commit_file release.txt "detached release" "Detached release commit" "2024-02-04T00:00:00Z"
|
||||
tag_release "0.22.4" "2024-02-05T00:00:00Z"
|
||||
|
||||
git checkout -q master
|
||||
|
||||
local out
|
||||
out="$(scripts/resolve-release-tags.sh HEAD)"
|
||||
|
||||
expect_output_value "$out" "latest_tag" "0.22.4"
|
||||
expect_output_value "$out" "last_reachable_tag" "0.22.3"
|
||||
expect_output_value "$out" "last_first_parent_tag" "0.22.3"
|
||||
expect_output_value "$out" "latest_tag_reachable" "false"
|
||||
|
||||
finish_test
|
||||
pass "test_reports_unreachable_latest_tag"
|
||||
}
|
||||
|
||||
test_falls_back_to_v_prefixed_tags() {
|
||||
echo "Running test_falls_back_to_v_prefixed_tags"
|
||||
run_test
|
||||
|
||||
commit_file README.md "base" "Initial commit" "2024-03-01T00:00:00Z"
|
||||
tag_release "v1.2.3" "2024-03-02T00:00:00Z"
|
||||
|
||||
local out
|
||||
out="$(scripts/resolve-release-tags.sh HEAD)"
|
||||
|
||||
expect_output_value "$out" "latest_tag" "v1.2.3"
|
||||
expect_output_value "$out" "last_reachable_tag" "v1.2.3"
|
||||
expect_output_value "$out" "last_first_parent_tag" "v1.2.3"
|
||||
expect_output_value "$out" "latest_tag_reachable" "true"
|
||||
|
||||
finish_test
|
||||
pass "test_falls_back_to_v_prefixed_tags"
|
||||
}
|
||||
|
||||
test_prefers_numeric_tags_over_v_prefixed_tags() {
|
||||
echo "Running test_prefers_numeric_tags_over_v_prefixed_tags"
|
||||
run_test
|
||||
|
||||
commit_file README.md "base" "Initial commit" "2024-04-01T00:00:00Z"
|
||||
tag_release "1.2.3" "2024-04-02T00:00:00Z"
|
||||
commit_file main.txt "master work" "Master commit" "2024-04-03T00:00:00Z"
|
||||
tag_release "v9.9.9" "2024-04-04T00:00:00Z"
|
||||
|
||||
local out
|
||||
out="$(scripts/resolve-release-tags.sh HEAD)"
|
||||
|
||||
expect_output_value "$out" "latest_tag" "1.2.3"
|
||||
expect_output_value "$out" "last_reachable_tag" "1.2.3"
|
||||
expect_output_value "$out" "last_first_parent_tag" "1.2.3"
|
||||
expect_output_value "$out" "latest_tag_reachable" "true"
|
||||
|
||||
finish_test
|
||||
pass "test_prefers_numeric_tags_over_v_prefixed_tags"
|
||||
}
|
||||
|
||||
test_returns_head_values_when_target_ref_is_missing() {
|
||||
echo "Running test_returns_head_values_when_target_ref_is_missing"
|
||||
run_test
|
||||
|
||||
commit_file README.md "base" "Initial commit" "2024-05-01T00:00:00Z"
|
||||
tag_release "0.30.0" "2024-05-02T00:00:00Z"
|
||||
|
||||
local out
|
||||
out="$(scripts/resolve-release-tags.sh refs/remotes/origin/master)"
|
||||
|
||||
expect_output_value "$out" "latest_tag" "0.30.0"
|
||||
expect_output_value "$out" "last_reachable_tag" "0.30.0"
|
||||
expect_output_value "$out" "last_first_parent_tag" "0.30.0"
|
||||
expect_output_value "$out" "latest_tag_reachable" "true"
|
||||
|
||||
finish_test
|
||||
pass "test_returns_head_values_when_target_ref_is_missing"
|
||||
}
|
||||
|
||||
test_returns_none_when_no_release_tags_exist
|
||||
test_prefers_reachable_tag_for_release_baseline
|
||||
test_reports_unreachable_latest_tag
|
||||
test_falls_back_to_v_prefixed_tags
|
||||
test_prefers_numeric_tags_over_v_prefixed_tags
|
||||
test_returns_head_values_when_target_ref_is_missing
|
||||
|
||||
echo
|
||||
echo "All resolve-release-tags tests passed."
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$ROOT/scripts/release/release_helpers.py"
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${TMP:-}" && -d "$TMP" ]]; then
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() { echo "[FAIL] $1" >&2; exit 1; }
|
||||
pass() { echo "[PASS] $1"; }
|
||||
|
||||
new_temp_dir() {
|
||||
mktemp -d "${TMPDIR:-/tmp}/snapshot-publication.XXXXXX"
|
||||
}
|
||||
|
||||
expect_output_value() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local expected="$3"
|
||||
local actual=""
|
||||
|
||||
actual="$(awk -F= -v key="$key" '$1 == key { print substr($0, length($1) + 2); exit }' "$file")"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
fail "expected ${key}=${expected}, got ${actual:-<missing>}"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_file_contains() {
|
||||
local file="$1"
|
||||
local expected="$2"
|
||||
|
||||
if ! grep -Fq -- "$expected" "$file"; then
|
||||
fail "expected ${file} to contain: ${expected}"
|
||||
fi
|
||||
}
|
||||
|
||||
write_metadata_fixture() {
|
||||
local path="$1"
|
||||
local latest="$2"
|
||||
local versions="$3"
|
||||
local last_updated="$4"
|
||||
|
||||
cat > "$path" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<metadata>
|
||||
<groupId>org.ta4j</groupId>
|
||||
<artifactId>ta4j-parent</artifactId>
|
||||
<versioning>
|
||||
<latest>${latest}</latest>
|
||||
<versions>
|
||||
${versions}
|
||||
</versions>
|
||||
<lastUpdated>${last_updated}</lastUpdated>
|
||||
</versioning>
|
||||
</metadata>
|
||||
EOF
|
||||
}
|
||||
|
||||
test_snapshot_version_present() {
|
||||
echo "Running test_snapshot_version_present"
|
||||
|
||||
TMP="$(new_temp_dir)"
|
||||
local metadata_file="$TMP/metadata.xml"
|
||||
local github_output="$TMP/github-output.txt"
|
||||
local output_file="$TMP/snapshot-publication.json"
|
||||
write_metadata_fixture "$metadata_file" "0.22.7-SNAPSHOT" $' <version>0.22.6-SNAPSHOT</version>\n <version>0.22.7-SNAPSHOT</version>' "20260506001534"
|
||||
|
||||
"$PYTHON" "$SCRIPT" snapshot-publication \
|
||||
--version "0.22.7-SNAPSHOT" \
|
||||
--metadata-file "$metadata_file" \
|
||||
--output "$output_file" \
|
||||
--github-output "$github_output" >/dev/null
|
||||
|
||||
expect_output_value "$github_output" "snapshot_publication" "true"
|
||||
expect_output_value "$github_output" "snapshot_publication_latest" "0.22.7-SNAPSHOT"
|
||||
expect_output_value "$github_output" "snapshot_publication_last_updated" "20260506001534"
|
||||
|
||||
rm -rf "$TMP"
|
||||
pass "test_snapshot_version_present"
|
||||
}
|
||||
|
||||
test_snapshot_version_missing() {
|
||||
echo "Running test_snapshot_version_missing"
|
||||
|
||||
TMP="$(new_temp_dir)"
|
||||
local metadata_file="$TMP/metadata.xml"
|
||||
local github_output="$TMP/github-output.txt"
|
||||
local output_file="$TMP/snapshot-publication.json"
|
||||
write_metadata_fixture "$metadata_file" "0.22.7-SNAPSHOT" $' <version>0.22.6-SNAPSHOT</version>\n <version>0.22.7-SNAPSHOT</version>' "20260506001534"
|
||||
|
||||
"$PYTHON" "$SCRIPT" snapshot-publication \
|
||||
--version "0.22.8-SNAPSHOT" \
|
||||
--metadata-file "$metadata_file" \
|
||||
--output "$output_file" \
|
||||
--github-output "$github_output" >/dev/null
|
||||
|
||||
expect_output_value "$github_output" "snapshot_publication" "false"
|
||||
expect_output_value "$github_output" "snapshot_publication_latest" "0.22.7-SNAPSHOT"
|
||||
|
||||
rm -rf "$TMP"
|
||||
pass "test_snapshot_version_missing"
|
||||
}
|
||||
|
||||
test_non_snapshot_version_returns_na() {
|
||||
echo "Running test_non_snapshot_version_returns_na"
|
||||
|
||||
TMP="$(new_temp_dir)"
|
||||
local metadata_file="$TMP/metadata.xml"
|
||||
local github_output="$TMP/github-output.txt"
|
||||
local output_file="$TMP/snapshot-publication.json"
|
||||
write_metadata_fixture "$metadata_file" "0.22.7-SNAPSHOT" $' <version>0.22.7-SNAPSHOT</version>' "20260506001534"
|
||||
|
||||
"$PYTHON" "$SCRIPT" snapshot-publication \
|
||||
--version "0.22.7" \
|
||||
--metadata-file "$metadata_file" \
|
||||
--output "$output_file" \
|
||||
--github-output "$github_output" >/dev/null
|
||||
|
||||
expect_output_value "$github_output" "snapshot_publication" "n/a"
|
||||
|
||||
rm -rf "$TMP"
|
||||
pass "test_non_snapshot_version_returns_na"
|
||||
}
|
||||
|
||||
test_malformed_metadata_returns_unknown() {
|
||||
echo "Running test_malformed_metadata_returns_unknown"
|
||||
|
||||
TMP="$(new_temp_dir)"
|
||||
local metadata_file="$TMP/metadata.xml"
|
||||
local github_output="$TMP/github-output.txt"
|
||||
local output_file="$TMP/snapshot-publication.json"
|
||||
printf '%s\n' '<metadata><versioning>' > "$metadata_file"
|
||||
|
||||
"$PYTHON" "$SCRIPT" snapshot-publication \
|
||||
--version "0.22.7-SNAPSHOT" \
|
||||
--metadata-file "$metadata_file" \
|
||||
--output "$output_file" \
|
||||
--github-output "$github_output" >/dev/null
|
||||
|
||||
expect_output_value "$github_output" "snapshot_publication" "unknown"
|
||||
|
||||
rm -rf "$TMP"
|
||||
pass "test_malformed_metadata_returns_unknown"
|
||||
}
|
||||
|
||||
test_non_https_metadata_url_returns_unknown() {
|
||||
echo "Running test_non_https_metadata_url_returns_unknown"
|
||||
|
||||
TMP="$(new_temp_dir)"
|
||||
local github_output="$TMP/github-output.txt"
|
||||
local output_file="$TMP/snapshot-publication.json"
|
||||
|
||||
"$PYTHON" "$SCRIPT" snapshot-publication \
|
||||
--version "0.22.7-SNAPSHOT" \
|
||||
--metadata-url "file:///tmp/snapshot-publication.xml" \
|
||||
--output "$output_file" \
|
||||
--github-output "$github_output" >/dev/null
|
||||
|
||||
expect_output_value "$github_output" "snapshot_publication" "unknown"
|
||||
expect_file_contains "$output_file" "\"error\": \"--metadata-url must use https\""
|
||||
|
||||
rm -rf "$TMP"
|
||||
pass "test_non_https_metadata_url_returns_unknown"
|
||||
}
|
||||
|
||||
test_snapshot_publication_policy_defers_push_runs() {
|
||||
echo "Running test_snapshot_publication_policy_defers_push_runs"
|
||||
|
||||
TMP="$(new_temp_dir)"
|
||||
local github_output="$TMP/github-output.txt"
|
||||
local output_file="$TMP/snapshot-publication-policy.json"
|
||||
|
||||
"$PYTHON" "$SCRIPT" snapshot-publication-policy \
|
||||
--event-name "push" \
|
||||
--workflow-name "" \
|
||||
--output "$output_file" \
|
||||
--github-output "$github_output" >/dev/null
|
||||
|
||||
expect_output_value "$github_output" "snapshot_publication_enforced" "false"
|
||||
expect_output_value "$github_output" "snapshot_publication_pending_reason" "awaiting snapshot workflow completion before treating snapshot metadata drift as authoritative"
|
||||
|
||||
rm -rf "$TMP"
|
||||
pass "test_snapshot_publication_policy_defers_push_runs"
|
||||
}
|
||||
|
||||
test_snapshot_publication_policy_enforces_snapshot_workflow_runs() {
|
||||
echo "Running test_snapshot_publication_policy_enforces_snapshot_workflow_runs"
|
||||
|
||||
TMP="$(new_temp_dir)"
|
||||
local github_output="$TMP/github-output.txt"
|
||||
local output_file="$TMP/snapshot-publication-policy.json"
|
||||
|
||||
"$PYTHON" "$SCRIPT" snapshot-publication-policy \
|
||||
--event-name "workflow_run" \
|
||||
--workflow-name "Publish Snapshot to Maven Central" \
|
||||
--output "$output_file" \
|
||||
--github-output "$github_output" >/dev/null
|
||||
|
||||
expect_output_value "$github_output" "snapshot_publication_enforced" "true"
|
||||
expect_output_value "$github_output" "snapshot_publication_pending_reason" ""
|
||||
|
||||
rm -rf "$TMP"
|
||||
pass "test_snapshot_publication_policy_enforces_snapshot_workflow_runs"
|
||||
}
|
||||
|
||||
test_snapshot_version_present
|
||||
test_snapshot_version_missing
|
||||
test_non_snapshot_version_returns_na
|
||||
test_malformed_metadata_returns_unknown
|
||||
test_non_https_metadata_url_returns_unknown
|
||||
test_snapshot_publication_policy_defers_push_runs
|
||||
test_snapshot_publication_policy_enforces_snapshot_workflow_runs
|
||||
|
||||
echo
|
||||
echo "All snapshot publication tests passed."
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# =============================================================================
|
||||
# test_validate_central_metadata.sh
|
||||
#
|
||||
# Validates behavior of scripts/validate-central-metadata.sh
|
||||
# =============================================================================
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$ROOT/scripts/validate-central-metadata.sh"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${TMP:-}" && -d "$TMP" ]]; then
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() { echo "[FAIL] $1" >&2; exit 1; }
|
||||
pass() { echo "[PASS] $1"; }
|
||||
|
||||
expect_contains() {
|
||||
local haystack="$1"
|
||||
local needle="$2"
|
||||
local msg="$3"
|
||||
if ! grep -Fq -- "$needle" <<<"$haystack"; then
|
||||
fail "$msg (missing: '$needle')"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_file_contains() {
|
||||
local file="$1"
|
||||
local needle="$2"
|
||||
local msg="$3"
|
||||
if ! grep -Fq -- "$needle" "$file"; then
|
||||
fail "$msg (missing: '$needle')"
|
||||
fi
|
||||
}
|
||||
|
||||
create_fixture() {
|
||||
local include_developers="$1"
|
||||
|
||||
mkdir -p ta4j-core ta4j-examples scripts
|
||||
cp "$SCRIPT" scripts/validate-central-metadata.sh
|
||||
chmod +x scripts/validate-central-metadata.sh
|
||||
|
||||
cat > pom.xml <<EOF
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>org.ta4j</groupId>
|
||||
<artifactId>ta4j-parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
<name>Ta4j Parent</name>
|
||||
<description>Fixture description</description>
|
||||
<url>https://github.com/ta4j/ta4j</url>
|
||||
<licenses>
|
||||
<license>
|
||||
<name>MIT License</name>
|
||||
</license>
|
||||
</licenses>
|
||||
<scm>
|
||||
<connection>scm:git:git://github.com/ta4j/ta4j.git</connection>
|
||||
<url>https://github.com/ta4j/ta4j</url>
|
||||
</scm>
|
||||
EOF
|
||||
|
||||
if [[ "$include_developers" == "true" ]]; then
|
||||
cat >> pom.xml <<'EOF'
|
||||
<developers>
|
||||
<developer>
|
||||
<id>maintainer</id>
|
||||
<name>Maintainer</name>
|
||||
</developer>
|
||||
</developers>
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat >> pom.xml <<'EOF'
|
||||
<modules>
|
||||
<module>ta4j-core</module>
|
||||
<module>ta4j-examples</module>
|
||||
</modules>
|
||||
</project>
|
||||
EOF
|
||||
|
||||
for module in ta4j-core ta4j-examples; do
|
||||
cat > "${module}/pom.xml" <<EOF
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.ta4j</groupId>
|
||||
<artifactId>ta4j-parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>${module}</artifactId>
|
||||
</project>
|
||||
EOF
|
||||
done
|
||||
}
|
||||
|
||||
run_test() {
|
||||
TMP="$(mktemp -d)"
|
||||
pushd "$TMP" >/dev/null || exit 1
|
||||
}
|
||||
|
||||
finish_test() {
|
||||
popd >/dev/null || exit 1
|
||||
rm -rf "$TMP"
|
||||
}
|
||||
|
||||
test_passes_with_complete_metadata() {
|
||||
echo "Running test_passes_with_complete_metadata"
|
||||
run_test
|
||||
create_fixture "true"
|
||||
|
||||
local out
|
||||
out="$(scripts/validate-central-metadata.sh)"
|
||||
expect_contains "$out" "validation passed" "validator should pass for complete metadata"
|
||||
|
||||
finish_test
|
||||
pass "test_passes_with_complete_metadata"
|
||||
}
|
||||
|
||||
test_fails_when_developers_missing() {
|
||||
echo "Running test_fails_when_developers_missing"
|
||||
run_test
|
||||
create_fixture "false"
|
||||
|
||||
local out_file="validator.log"
|
||||
if scripts/validate-central-metadata.sh >"$out_file" 2>&1; then
|
||||
fail "validator should fail when developers metadata is missing"
|
||||
fi
|
||||
|
||||
expect_file_contains "$out_file" "developers[0].id" "should mention missing developer id"
|
||||
expect_file_contains "$out_file" "developers[0].name" "should mention missing developer name"
|
||||
expect_file_contains "$out_file" "validation failed" "should print summary failure"
|
||||
|
||||
finish_test
|
||||
pass "test_fails_when_developers_missing"
|
||||
}
|
||||
|
||||
test_passes_with_complete_metadata
|
||||
test_fails_when_developers_missing
|
||||
|
||||
echo
|
||||
echo "All validate-central-metadata tests passed."
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# =============================================================================
|
||||
# validate-central-metadata.sh
|
||||
#
|
||||
# Validates Maven Central-required metadata before release publication.
|
||||
# Uses Maven's evaluated effective model values (no XML parsing dependency).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/validate-central-metadata.sh
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
for tool in mvn; do
|
||||
command -v "$tool" >/dev/null 2>&1 || {
|
||||
echo "Error: required tool '$tool' not found" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
declare -a ERRORS=()
|
||||
|
||||
add_error() {
|
||||
local message="$1"
|
||||
ERRORS+=("$message")
|
||||
echo "::error::$message"
|
||||
}
|
||||
|
||||
module_label() {
|
||||
local module="$1"
|
||||
if [[ "$module" == "ta4j-parent" ]]; then
|
||||
echo "ta4j-parent (root)"
|
||||
else
|
||||
echo "$module"
|
||||
fi
|
||||
}
|
||||
|
||||
evaluate_expression() {
|
||||
local module="$1"
|
||||
local expression="$2"
|
||||
local -a cmd=(mvn -q)
|
||||
|
||||
if [[ "$module" == "ta4j-parent" ]]; then
|
||||
cmd+=(-N)
|
||||
else
|
||||
cmd+=(-pl "$module")
|
||||
fi
|
||||
|
||||
cmd+=(help:evaluate "-Dexpression=${expression}" -DforceStdout)
|
||||
|
||||
local output
|
||||
if ! output="$("${cmd[@]}" 2>&1)"; then
|
||||
add_error "$(module_label "$module"): failed to evaluate '${expression}'"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local value
|
||||
value="$(printf '%s\n' "$output" | sed '/^[[:space:]]*$/d' | tail -n1 | tr -d '\r')"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
is_missing_value() {
|
||||
local value="$1"
|
||||
[[ -z "$value" || "$value" == "null object or invalid expression" ]]
|
||||
}
|
||||
|
||||
check_required() {
|
||||
local module="$1"
|
||||
local expression="$2"
|
||||
local field_name="$3"
|
||||
|
||||
local value
|
||||
if ! value="$(evaluate_expression "$module" "$expression")"; then
|
||||
return
|
||||
fi
|
||||
|
||||
if is_missing_value "$value"; then
|
||||
add_error "$(module_label "$module"): missing required '${field_name}' (${expression})"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_module() {
|
||||
local module="$1"
|
||||
|
||||
check_required "$module" "project.name" "name"
|
||||
check_required "$module" "project.description" "description"
|
||||
check_required "$module" "project.url" "url"
|
||||
check_required "$module" "project.licenses[0].name" "licenses[0].name"
|
||||
check_required "$module" "project.scm.connection" "scm.connection"
|
||||
check_required "$module" "project.scm.url" "scm.url"
|
||||
check_required "$module" "project.developers[0].id" "developers[0].id"
|
||||
check_required "$module" "project.developers[0].name" "developers[0].name"
|
||||
}
|
||||
|
||||
echo "Validating Maven Central metadata prerequisites..."
|
||||
for module in ta4j-parent ta4j-core ta4j-examples; do
|
||||
validate_module "$module"
|
||||
done
|
||||
|
||||
if [[ ${#ERRORS[@]} -gt 0 ]]; then
|
||||
echo "Maven Central metadata validation failed with ${#ERRORS[@]} issue(s)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Maven Central metadata validation passed for ta4j-parent, ta4j-core, ta4j-examples."
|
||||
Reference in New Issue
Block a user