goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
+27
View File
@@ -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`.
+68
View File
@@ -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
View File
@@ -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"
+161
View File
@@ -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
+226
View File
@@ -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())
+101
View File
@@ -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"
+401
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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."