Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -0,0 +1,350 @@
#!/usr/bin/env python3
"""
bump-service-version.py - Bump service version in centralized version storage
Sprint: CI/CD Enhancement - Per-Service Auto-Versioning
This script manages service versions stored in src/Directory.Versions.props
and devops/releases/service-versions.json.
Usage:
python bump-service-version.py <service> <bump-type> [options]
python bump-service-version.py authority patch
python bump-service-version.py scanner minor --dry-run
python bump-service-version.py cli major --commit
Arguments:
service Service name (authority, attestor, concelier, scanner, etc.)
bump-type Version bump type: major, minor, patch, or explicit version (e.g., 2.0.0)
Options:
--dry-run Show what would be changed without modifying files
--commit Commit changes to git after updating
--no-manifest Skip updating service-versions.json manifest
--git-sha SHA Git SHA to record in manifest (defaults to HEAD)
--docker-tag TAG Docker tag to record in manifest
"""
import argparse
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Tuple
# Repository paths
SCRIPT_DIR = Path(__file__).parent
REPO_ROOT = SCRIPT_DIR.parent.parent.parent
VERSIONS_FILE = REPO_ROOT / "src" / "Directory.Versions.props"
MANIFEST_FILE = REPO_ROOT / "devops" / "releases" / "service-versions.json"
# Service name mapping (lowercase key -> property suffix)
SERVICE_MAP = {
"authority": "Authority",
"attestor": "Attestor",
"concelier": "Concelier",
"scanner": "Scanner",
"policy": "Policy",
"signer": "Signer",
"excititor": "Excititor",
"gateway": "Gateway",
"scheduler": "Scheduler",
"cli": "Cli",
"orchestrator": "Orchestrator",
"notify": "Notify",
"sbomservice": "SbomService",
"vexhub": "VexHub",
"evidencelocker": "EvidenceLocker",
}
def parse_version(version_str: str) -> Tuple[int, int, int]:
"""Parse semantic version string into tuple."""
match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_str)
if not match:
raise ValueError(f"Invalid version format: {version_str}")
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def format_version(major: int, minor: int, patch: int) -> str:
"""Format version tuple as string."""
return f"{major}.{minor}.{patch}"
def bump_version(current: str, bump_type: str) -> str:
"""Bump version according to bump type."""
# Check if bump_type is an explicit version
if re.match(r"^\d+\.\d+\.\d+$", bump_type):
return bump_type
major, minor, patch = parse_version(current)
if bump_type == "major":
return format_version(major + 1, 0, 0)
elif bump_type == "minor":
return format_version(major, minor + 1, 0)
elif bump_type == "patch":
return format_version(major, minor, patch + 1)
else:
raise ValueError(f"Invalid bump type: {bump_type}")
def read_version_from_props(service_key: str) -> Optional[str]:
"""Read current version from Directory.Versions.props."""
if not VERSIONS_FILE.exists():
return None
property_name = f"StellaOps{SERVICE_MAP[service_key]}Version"
pattern = rf"<{property_name}>(\d+\.\d+\.\d+)</{property_name}>"
content = VERSIONS_FILE.read_text(encoding="utf-8")
match = re.search(pattern, content)
return match.group(1) if match else None
def update_version_in_props(service_key: str, new_version: str, dry_run: bool = False) -> bool:
"""Update version in Directory.Versions.props."""
if not VERSIONS_FILE.exists():
print(f"Error: {VERSIONS_FILE} not found", file=sys.stderr)
return False
property_name = f"StellaOps{SERVICE_MAP[service_key]}Version"
pattern = rf"(<{property_name}>)\d+\.\d+\.\d+(</{property_name}>)"
replacement = rf"\g<1>{new_version}\g<2>"
content = VERSIONS_FILE.read_text(encoding="utf-8")
new_content, count = re.subn(pattern, replacement, content)
if count == 0:
print(f"Error: Property {property_name} not found in {VERSIONS_FILE}", file=sys.stderr)
return False
if dry_run:
print(f"[DRY-RUN] Would update {VERSIONS_FILE}")
print(f"[DRY-RUN] {property_name}: {new_version}")
else:
VERSIONS_FILE.write_text(new_content, encoding="utf-8")
print(f"Updated {VERSIONS_FILE}")
print(f" {property_name}: {new_version}")
return True
def update_manifest(
service_key: str,
new_version: str,
git_sha: Optional[str] = None,
docker_tag: Optional[str] = None,
dry_run: bool = False,
) -> bool:
"""Update service-versions.json manifest."""
if not MANIFEST_FILE.exists():
print(f"Warning: {MANIFEST_FILE} not found, skipping manifest update", file=sys.stderr)
return True
try:
manifest = json.loads(MANIFEST_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
print(f"Error parsing {MANIFEST_FILE}: {e}", file=sys.stderr)
return False
if service_key not in manifest.get("services", {}):
print(f"Warning: Service '{service_key}' not found in manifest", file=sys.stderr)
return True
# Update service entry
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
service = manifest["services"][service_key]
service["version"] = new_version
service["releasedAt"] = now
if git_sha:
service["gitSha"] = git_sha
if docker_tag:
service["dockerTag"] = docker_tag
# Update manifest timestamp
manifest["lastUpdated"] = now
if dry_run:
print(f"[DRY-RUN] Would update {MANIFEST_FILE}")
print(f"[DRY-RUN] {service_key}.version: {new_version}")
if docker_tag:
print(f"[DRY-RUN] {service_key}.dockerTag: {docker_tag}")
else:
MANIFEST_FILE.write_text(
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(f"Updated {MANIFEST_FILE}")
return True
def get_git_sha() -> Optional[str]:
"""Get current git HEAD SHA."""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
cwd=REPO_ROOT,
check=True,
)
return result.stdout.strip()[:12] # Short SHA
except subprocess.CalledProcessError:
return None
def commit_changes(service_key: str, old_version: str, new_version: str) -> bool:
"""Commit version changes to git."""
try:
# Stage the files
subprocess.run(
["git", "add", str(VERSIONS_FILE), str(MANIFEST_FILE)],
cwd=REPO_ROOT,
check=True,
)
# Create commit
commit_msg = f"""chore({service_key}): bump version {old_version} -> {new_version}
Automated version bump via bump-service-version.py
Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>"""
subprocess.run(
["git", "commit", "-m", commit_msg],
cwd=REPO_ROOT,
check=True,
)
print(f"Committed version bump: {old_version} -> {new_version}")
return True
except subprocess.CalledProcessError as e:
print(f"Error committing changes: {e}", file=sys.stderr)
return False
def generate_docker_tag(version: str) -> str:
"""Generate Docker tag with datetime suffix: {version}+{YYYYMMDDHHmmss}."""
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
return f"{version}+{timestamp}"
def main():
parser = argparse.ArgumentParser(
description="Bump service version in centralized version storage",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s authority patch # Bump authority from 1.0.0 to 1.0.1
%(prog)s scanner minor --dry-run # Preview bumping scanner minor version
%(prog)s cli 2.0.0 --commit # Set CLI to 2.0.0 and commit
%(prog)s gateway patch --docker-tag # Bump and generate docker tag
""",
)
parser.add_argument(
"service",
choices=list(SERVICE_MAP.keys()),
help="Service name to bump",
)
parser.add_argument(
"bump_type",
help="Bump type: major, minor, patch, or explicit version (e.g., 2.0.0)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be changed without modifying files",
)
parser.add_argument(
"--commit",
action="store_true",
help="Commit changes to git after updating",
)
parser.add_argument(
"--no-manifest",
action="store_true",
help="Skip updating service-versions.json manifest",
)
parser.add_argument(
"--git-sha",
help="Git SHA to record in manifest (defaults to HEAD)",
)
parser.add_argument(
"--docker-tag",
nargs="?",
const="auto",
help="Docker tag to record in manifest (use 'auto' to generate)",
)
parser.add_argument(
"--output-version",
action="store_true",
help="Output only the new version (for CI scripts)",
)
parser.add_argument(
"--output-docker-tag",
action="store_true",
help="Output only the docker tag (for CI scripts)",
)
args = parser.parse_args()
# Read current version
current_version = read_version_from_props(args.service)
if not current_version:
print(f"Error: Could not read current version for {args.service}", file=sys.stderr)
sys.exit(1)
# Calculate new version
try:
new_version = bump_version(current_version, args.bump_type)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Generate docker tag if requested
docker_tag = None
if args.docker_tag:
docker_tag = generate_docker_tag(new_version) if args.docker_tag == "auto" else args.docker_tag
# Output mode for CI scripts
if args.output_version:
print(new_version)
sys.exit(0)
if args.output_docker_tag:
print(docker_tag or generate_docker_tag(new_version))
sys.exit(0)
# Print summary
print(f"Service: {args.service}")
print(f"Current version: {current_version}")
print(f"New version: {new_version}")
if docker_tag:
print(f"Docker tag: {docker_tag}")
print()
# Update version in props file
if not update_version_in_props(args.service, new_version, args.dry_run):
sys.exit(1)
# Update manifest if not skipped
if not args.no_manifest:
git_sha = args.git_sha or get_git_sha()
if not update_manifest(args.service, new_version, git_sha, docker_tag, args.dry_run):
sys.exit(1)
# Commit if requested
if args.commit and not args.dry_run:
if not commit_changes(args.service, current_version, new_version):
sys.exit(1)
print()
print(f"Successfully bumped {args.service}: {current_version} -> {new_version}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""
collect_versions.py - Collect service versions for suite release
Sprint: CI/CD Enhancement - Suite Release Pipeline
Gathers all service versions from Directory.Versions.props and service-versions.json.
Usage:
python collect_versions.py [options]
python collect_versions.py --format json
python collect_versions.py --format yaml --output versions.yaml
Options:
--format FMT Output format: json, yaml, markdown, env (default: json)
--output FILE Output file (defaults to stdout)
--include-unreleased Include services with no Docker tag
--registry URL Container registry URL
"""
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
# Repository paths
SCRIPT_DIR = Path(__file__).parent
REPO_ROOT = SCRIPT_DIR.parent.parent.parent
VERSIONS_FILE = REPO_ROOT / "src" / "Directory.Versions.props"
MANIFEST_FILE = REPO_ROOT / "devops" / "releases" / "service-versions.json"
# Default registry
DEFAULT_REGISTRY = "git.stella-ops.org/stella-ops.org"
@dataclass
class ServiceVersion:
name: str
version: str
docker_tag: Optional[str] = None
released_at: Optional[str] = None
git_sha: Optional[str] = None
image: Optional[str] = None
def read_versions_from_props() -> Dict[str, str]:
"""Read versions from Directory.Versions.props."""
if not VERSIONS_FILE.exists():
print(f"Warning: {VERSIONS_FILE} not found", file=sys.stderr)
return {}
content = VERSIONS_FILE.read_text(encoding="utf-8")
versions = {}
# Pattern: <StellaOps{Service}Version>X.Y.Z</StellaOps{Service}Version>
pattern = r"<StellaOps(\w+)Version>(\d+\.\d+\.\d+)</StellaOps\1Version>"
for match in re.finditer(pattern, content):
service_name = match.group(1)
version = match.group(2)
versions[service_name.lower()] = version
return versions
def read_manifest() -> Dict[str, dict]:
"""Read service metadata from manifest file."""
if not MANIFEST_FILE.exists():
print(f"Warning: {MANIFEST_FILE} not found", file=sys.stderr)
return {}
try:
manifest = json.loads(MANIFEST_FILE.read_text(encoding="utf-8"))
return manifest.get("services", {})
except json.JSONDecodeError as e:
print(f"Warning: Failed to parse {MANIFEST_FILE}: {e}", file=sys.stderr)
return {}
def collect_all_versions(
registry: str = DEFAULT_REGISTRY,
include_unreleased: bool = False,
) -> List[ServiceVersion]:
"""Collect all service versions."""
props_versions = read_versions_from_props()
manifest_services = read_manifest()
services = []
# Merge data from both sources
all_service_keys = set(props_versions.keys()) | set(manifest_services.keys())
for key in sorted(all_service_keys):
version = props_versions.get(key, "0.0.0")
manifest = manifest_services.get(key, {})
docker_tag = manifest.get("dockerTag")
released_at = manifest.get("releasedAt")
git_sha = manifest.get("gitSha")
# Skip unreleased if not requested
if not include_unreleased and not docker_tag:
continue
# Build image reference
if docker_tag:
image = f"{registry}/{key}:{docker_tag}"
else:
image = f"{registry}/{key}:{version}"
service = ServiceVersion(
name=manifest.get("name", key.title()),
version=version,
docker_tag=docker_tag,
released_at=released_at,
git_sha=git_sha,
image=image,
)
services.append(service)
return services
def format_json(services: List[ServiceVersion]) -> str:
"""Format as JSON."""
data = {
"generatedAt": datetime.now(timezone.utc).isoformat(),
"services": [asdict(s) for s in services],
}
return json.dumps(data, indent=2, ensure_ascii=False)
def format_yaml(services: List[ServiceVersion]) -> str:
"""Format as YAML."""
lines = [
"# Service Versions",
f"# Generated: {datetime.now(timezone.utc).isoformat()}",
"",
"services:",
]
for s in services:
lines.extend([
f" {s.name.lower()}:",
f" name: {s.name}",
f" version: \"{s.version}\"",
])
if s.docker_tag:
lines.append(f" dockerTag: \"{s.docker_tag}\"")
if s.image:
lines.append(f" image: \"{s.image}\"")
if s.released_at:
lines.append(f" releasedAt: \"{s.released_at}\"")
if s.git_sha:
lines.append(f" gitSha: \"{s.git_sha}\"")
return "\n".join(lines)
def format_markdown(services: List[ServiceVersion]) -> str:
"""Format as Markdown table."""
lines = [
"# Service Versions",
"",
f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
"",
"| Service | Version | Docker Tag | Released |",
"|---------|---------|------------|----------|",
]
for s in services:
released = s.released_at[:10] if s.released_at else "-"
docker_tag = f"`{s.docker_tag}`" if s.docker_tag else "-"
lines.append(f"| {s.name} | {s.version} | {docker_tag} | {released} |")
return "\n".join(lines)
def format_env(services: List[ServiceVersion]) -> str:
"""Format as environment variables."""
lines = [
"# Service Versions as Environment Variables",
f"# Generated: {datetime.now(timezone.utc).isoformat()}",
"",
]
for s in services:
name_upper = s.name.upper().replace(" ", "_")
lines.append(f"STELLAOPS_{name_upper}_VERSION={s.version}")
if s.docker_tag:
lines.append(f"STELLAOPS_{name_upper}_DOCKER_TAG={s.docker_tag}")
if s.image:
lines.append(f"STELLAOPS_{name_upper}_IMAGE={s.image}")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Collect service versions for suite release",
)
parser.add_argument(
"--format",
choices=["json", "yaml", "markdown", "env"],
default="json",
help="Output format",
)
parser.add_argument("--output", "-o", help="Output file")
parser.add_argument(
"--include-unreleased",
action="store_true",
help="Include services without Docker tags",
)
parser.add_argument(
"--registry",
default=DEFAULT_REGISTRY,
help="Container registry URL",
)
args = parser.parse_args()
# Collect versions
services = collect_all_versions(
registry=args.registry,
include_unreleased=args.include_unreleased,
)
if not services:
print("No services found", file=sys.stderr)
if not args.include_unreleased:
print("Hint: Use --include-unreleased to show all services", file=sys.stderr)
sys.exit(0)
# Format output
formatters = {
"json": format_json,
"yaml": format_yaml,
"markdown": format_markdown,
"env": format_env,
}
output = formatters[args.format](services)
# Write output
if args.output:
Path(args.output).write_text(output, encoding="utf-8")
print(f"Versions written to: {args.output}", file=sys.stderr)
else:
print(output)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,130 @@
#!/bin/bash
# generate-docker-tag.sh - Generate Docker tag with datetime suffix
#
# Sprint: CI/CD Enhancement - Per-Service Auto-Versioning
# Generates Docker tags in format: {semver}+{YYYYMMDDHHmmss}
#
# Usage:
# ./generate-docker-tag.sh <service>
# ./generate-docker-tag.sh --version <version>
# ./generate-docker-tag.sh authority
# ./generate-docker-tag.sh --version 1.2.3
#
# Output:
# Prints the Docker tag to stdout (e.g., "1.2.3+20250128143022")
# Exit code 0 on success, 1 on error
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
usage() {
cat << EOF
Usage: $(basename "$0") <service|--version VERSION>
Generate Docker tag with datetime suffix.
Format: {semver}+{YYYYMMDDHHmmss}
Example: 1.2.3+20250128143022
Arguments:
service Service name to read version from
--version VERSION Use explicit version instead of reading from file
Options:
--timestamp TS Use explicit timestamp (YYYYMMDDHHmmss format)
--output-parts Output version and timestamp separately (JSON)
--help, -h Show this help message
Examples:
$(basename "$0") authority # 1.0.0+20250128143022
$(basename "$0") --version 2.0.0 # 2.0.0+20250128143022
$(basename "$0") scanner --timestamp 20250101120000
$(basename "$0") --version 1.0.0 --output-parts
EOF
}
# Generate timestamp in UTC
generate_timestamp() {
date -u +"%Y%m%d%H%M%S"
}
main() {
local version=""
local timestamp=""
local output_parts=false
local service=""
while [[ $# -gt 0 ]]; do
case "$1" in
--help|-h)
usage
exit 0
;;
--version)
version="$2"
shift 2
;;
--timestamp)
timestamp="$2"
shift 2
;;
--output-parts)
output_parts=true
shift
;;
-*)
echo "Error: Unknown option: $1" >&2
usage
exit 1
;;
*)
service="$1"
shift
;;
esac
done
# Get version from service if not explicitly provided
if [[ -z "$version" ]]; then
if [[ -z "$service" ]]; then
echo "Error: Either service name or --version must be provided" >&2
usage
exit 1
fi
# Read version using read-service-version.sh
if [[ ! -x "${SCRIPT_DIR}/read-service-version.sh" ]]; then
echo "Error: read-service-version.sh not found or not executable" >&2
exit 1
fi
version=$("${SCRIPT_DIR}/read-service-version.sh" "$service")
fi
# Validate version format
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Invalid version format: $version (expected: X.Y.Z)" >&2
exit 1
fi
# Generate timestamp if not provided
if [[ -z "$timestamp" ]]; then
timestamp=$(generate_timestamp)
fi
# Validate timestamp format
if ! [[ "$timestamp" =~ ^[0-9]{14}$ ]]; then
echo "Error: Invalid timestamp format: $timestamp (expected: YYYYMMDDHHmmss)" >&2
exit 1
fi
# Output
if [[ "$output_parts" == "true" ]]; then
echo "{\"version\":\"$version\",\"timestamp\":\"$timestamp\",\"tag\":\"${version}+${timestamp}\"}"
else
echo "${version}+${timestamp}"
fi
}
main "$@"

View File

@@ -0,0 +1,448 @@
#!/usr/bin/env python3
"""
generate_changelog.py - AI-assisted changelog generation for suite releases
Sprint: CI/CD Enhancement - Suite Release Pipeline
Generates changelogs from git commit history with optional AI enhancement.
Usage:
python generate_changelog.py <version> [options]
python generate_changelog.py 2026.04 --codename Nova
python generate_changelog.py 2026.04 --from-tag suite-2025.10 --ai
Arguments:
version Suite version (YYYY.MM format)
Options:
--codename NAME Release codename
--from-tag TAG Previous release tag (defaults to latest suite-* tag)
--to-ref REF End reference (defaults to HEAD)
--ai Use AI to enhance changelog descriptions
--output FILE Output file (defaults to stdout)
--format FMT Output format: markdown, json (default: markdown)
"""
import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from collections import defaultdict
# Repository paths
SCRIPT_DIR = Path(__file__).parent
REPO_ROOT = SCRIPT_DIR.parent.parent.parent
# Module patterns for categorization
MODULE_PATTERNS = {
"Authority": r"src/Authority/",
"Attestor": r"src/Attestor/",
"Concelier": r"src/Concelier/",
"Scanner": r"src/Scanner/",
"Policy": r"src/Policy/",
"Signer": r"src/Signer/",
"Excititor": r"src/Excititor/",
"Gateway": r"src/Gateway/",
"Scheduler": r"src/Scheduler/",
"CLI": r"src/Cli/",
"Orchestrator": r"src/Orchestrator/",
"Notify": r"src/Notify/",
"Infrastructure": r"(devops/|\.gitea/|docs/)",
"Core": r"src/__Libraries/",
}
# Commit type patterns (conventional commits)
COMMIT_TYPE_PATTERNS = {
"breaking": r"^(feat|fix|refactor)(\(.+\))?!:|BREAKING CHANGE:",
"security": r"^(security|fix)(\(.+\))?:|CVE-|vulnerability|exploit",
"feature": r"^feat(\(.+\))?:",
"fix": r"^fix(\(.+\))?:",
"performance": r"^perf(\(.+\))?:|performance|optimize",
"refactor": r"^refactor(\(.+\))?:",
"docs": r"^docs(\(.+\))?:",
"test": r"^test(\(.+\))?:",
"chore": r"^chore(\(.+\))?:|^ci(\(.+\))?:|^build(\(.+\))?:",
}
@dataclass
class Commit:
sha: str
short_sha: str
message: str
body: str
author: str
date: str
files: List[str] = field(default_factory=list)
type: str = "other"
module: str = "Other"
scope: str = ""
@dataclass
class ChangelogEntry:
description: str
commits: List[Commit]
module: str
type: str
def run_git(args: List[str], cwd: Path = REPO_ROOT) -> str:
"""Run git command and return output."""
result = subprocess.run(
["git"] + args,
capture_output=True,
text=True,
cwd=cwd,
)
if result.returncode != 0:
raise RuntimeError(f"Git command failed: {result.stderr}")
return result.stdout.strip()
def get_latest_suite_tag() -> Optional[str]:
"""Get the most recent suite-* tag."""
try:
output = run_git(["tag", "-l", "suite-*", "--sort=-creatordate"])
tags = output.split("\n")
return tags[0] if tags and tags[0] else None
except RuntimeError:
return None
def get_commits_between(from_ref: str, to_ref: str = "HEAD") -> List[Commit]:
"""Get commits between two refs."""
# Format: sha|short_sha|subject|body|author|date
format_str = "%H|%h|%s|%b|%an|%aI"
separator = "---COMMIT_SEPARATOR---"
try:
output = run_git([
"log",
f"{from_ref}..{to_ref}",
f"--format={format_str}{separator}",
"--name-only",
])
except RuntimeError:
# If from_ref doesn't exist, get all commits up to to_ref
output = run_git([
"log",
to_ref,
"-100", # Limit to last 100 commits
f"--format={format_str}{separator}",
"--name-only",
])
commits = []
entries = output.split(separator)
for entry in entries:
entry = entry.strip()
if not entry:
continue
lines = entry.split("\n")
if not lines:
continue
# Parse commit info
parts = lines[0].split("|")
if len(parts) < 6:
continue
# Get changed files (remaining lines after commit info)
files = [f.strip() for f in lines[1:] if f.strip()]
commit = Commit(
sha=parts[0],
short_sha=parts[1],
message=parts[2],
body=parts[3] if len(parts) > 3 else "",
author=parts[4] if len(parts) > 4 else "",
date=parts[5] if len(parts) > 5 else "",
files=files,
)
# Categorize commit
commit.type = categorize_commit_type(commit.message)
commit.module = categorize_commit_module(commit.files, commit.message)
commit.scope = extract_scope(commit.message)
commits.append(commit)
return commits
def categorize_commit_type(message: str) -> str:
"""Categorize commit by type based on message."""
message_lower = message.lower()
for commit_type, pattern in COMMIT_TYPE_PATTERNS.items():
if re.search(pattern, message, re.IGNORECASE):
return commit_type
return "other"
def categorize_commit_module(files: List[str], message: str) -> str:
"""Categorize commit by module based on changed files."""
module_counts: Dict[str, int] = defaultdict(int)
for file in files:
for module, pattern in MODULE_PATTERNS.items():
if re.search(pattern, file):
module_counts[module] += 1
break
if module_counts:
return max(module_counts, key=module_counts.get)
# Try to extract from message scope
scope_match = re.match(r"^\w+\((\w+)\):", message)
if scope_match:
scope = scope_match.group(1).lower()
for module in MODULE_PATTERNS:
if module.lower() == scope:
return module
return "Other"
def extract_scope(message: str) -> str:
"""Extract scope from conventional commit message."""
match = re.match(r"^\w+\(([^)]+)\):", message)
return match.group(1) if match else ""
def group_commits_by_type_and_module(
commits: List[Commit],
) -> Dict[str, Dict[str, List[Commit]]]:
"""Group commits by type and module."""
grouped: Dict[str, Dict[str, List[Commit]]] = defaultdict(lambda: defaultdict(list))
for commit in commits:
grouped[commit.type][commit.module].append(commit)
return grouped
def generate_markdown_changelog(
version: str,
codename: str,
commits: List[Commit],
ai_enhanced: bool = False,
) -> str:
"""Generate markdown changelog."""
grouped = group_commits_by_type_and_module(commits)
lines = [
f"# Changelog - StellaOps {version} \"{codename}\"",
"",
f"Release Date: {datetime.now(timezone.utc).strftime('%Y-%m-%d')}",
"",
]
# Order of sections
section_order = [
("breaking", "Breaking Changes"),
("security", "Security"),
("feature", "Features"),
("fix", "Bug Fixes"),
("performance", "Performance"),
("refactor", "Refactoring"),
("docs", "Documentation"),
("other", "Other Changes"),
]
for type_key, section_title in section_order:
if type_key not in grouped:
continue
modules = grouped[type_key]
if not modules:
continue
lines.append(f"## {section_title}")
lines.append("")
# Sort modules alphabetically
for module in sorted(modules.keys()):
commits_in_module = modules[module]
if not commits_in_module:
continue
lines.append(f"### {module}")
lines.append("")
for commit in commits_in_module:
# Clean up message
msg = commit.message
# Remove conventional commit prefix for display
msg = re.sub(r"^\w+(\([^)]+\))?[!]?:\s*", "", msg)
if ai_enhanced:
# Placeholder for AI-enhanced description
lines.append(f"- {msg} ([{commit.short_sha}])")
else:
lines.append(f"- {msg} (`{commit.short_sha}`)")
lines.append("")
# Add statistics
lines.extend([
"---",
"",
"## Statistics",
"",
f"- **Total Commits:** {len(commits)}",
f"- **Contributors:** {len(set(c.author for c in commits))}",
f"- **Files Changed:** {len(set(f for c in commits for f in c.files))}",
"",
])
return "\n".join(lines)
def generate_json_changelog(
version: str,
codename: str,
commits: List[Commit],
) -> str:
"""Generate JSON changelog."""
grouped = group_commits_by_type_and_module(commits)
changelog = {
"version": version,
"codename": codename,
"date": datetime.now(timezone.utc).isoformat(),
"statistics": {
"totalCommits": len(commits),
"contributors": len(set(c.author for c in commits)),
"filesChanged": len(set(f for c in commits for f in c.files)),
},
"sections": {},
}
for type_key, modules in grouped.items():
if not modules:
continue
changelog["sections"][type_key] = {}
for module, module_commits in modules.items():
changelog["sections"][type_key][module] = [
{
"sha": c.short_sha,
"message": c.message,
"author": c.author,
"date": c.date,
}
for c in module_commits
]
return json.dumps(changelog, indent=2, ensure_ascii=False)
def enhance_with_ai(changelog: str, api_key: Optional[str] = None) -> str:
"""Enhance changelog using AI (if available)."""
if not api_key:
api_key = os.environ.get("AI_API_KEY")
if not api_key:
print("Warning: No AI API key provided, skipping AI enhancement", file=sys.stderr)
return changelog
# This is a placeholder for AI integration
# In production, this would call Claude API or similar
prompt = f"""
You are a technical writer creating release notes for a security platform.
Improve the following changelog by:
1. Making descriptions more user-friendly
2. Highlighting important changes
3. Adding context where helpful
4. Keeping it concise
Original changelog:
{changelog}
Generate improved changelog in the same markdown format.
"""
# For now, return the original changelog
# TODO: Implement actual AI API call
print("Note: AI enhancement is a placeholder, returning original changelog", file=sys.stderr)
return changelog
def main():
parser = argparse.ArgumentParser(
description="Generate changelog from git history",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("version", help="Suite version (YYYY.MM format)")
parser.add_argument("--codename", default="", help="Release codename")
parser.add_argument("--from-tag", help="Previous release tag")
parser.add_argument("--to-ref", default="HEAD", help="End reference")
parser.add_argument("--ai", action="store_true", help="Use AI enhancement")
parser.add_argument("--output", "-o", help="Output file")
parser.add_argument(
"--format",
choices=["markdown", "json"],
default="markdown",
help="Output format",
)
args = parser.parse_args()
# Validate version format
if not re.match(r"^\d{4}\.(04|10)$", args.version):
print(f"Warning: Non-standard version format: {args.version}", file=sys.stderr)
# Determine from tag
from_tag = args.from_tag
if not from_tag:
from_tag = get_latest_suite_tag()
if from_tag:
print(f"Using previous tag: {from_tag}", file=sys.stderr)
else:
print("No previous suite tag found, using last 100 commits", file=sys.stderr)
from_tag = "HEAD~100"
# Get commits
print(f"Collecting commits from {from_tag} to {args.to_ref}...", file=sys.stderr)
commits = get_commits_between(from_tag, args.to_ref)
print(f"Found {len(commits)} commits", file=sys.stderr)
if not commits:
print("No commits found in range", file=sys.stderr)
sys.exit(0)
# Generate changelog
codename = args.codename or "TBD"
if args.format == "json":
output = generate_json_changelog(args.version, codename, commits)
else:
output = generate_markdown_changelog(
args.version, codename, commits, ai_enhanced=args.ai
)
if args.ai:
output = enhance_with_ai(output)
# Output
if args.output:
Path(args.output).write_text(output, encoding="utf-8")
print(f"Changelog written to: {args.output}", file=sys.stderr)
else:
print(output)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,373 @@
#!/usr/bin/env python3
"""
generate_compose.py - Generate pinned Docker Compose files for suite releases
Sprint: CI/CD Enhancement - Suite Release Pipeline
Creates docker-compose.yml files with pinned image versions for releases.
Usage:
python generate_compose.py <version> <codename> [options]
python generate_compose.py 2026.04 Nova --output docker-compose.yml
python generate_compose.py 2026.04 Nova --airgap --output docker-compose.airgap.yml
Arguments:
version Suite version (YYYY.MM format)
codename Release codename
Options:
--output FILE Output file (default: stdout)
--airgap Generate air-gap variant
--registry URL Container registry URL
--include-deps Include infrastructure dependencies (postgres, valkey)
"""
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
# Repository paths
SCRIPT_DIR = Path(__file__).parent
REPO_ROOT = SCRIPT_DIR.parent.parent.parent
MANIFEST_FILE = REPO_ROOT / "devops" / "releases" / "service-versions.json"
# Default registry
DEFAULT_REGISTRY = "git.stella-ops.org/stella-ops.org"
# Service definitions with port mappings and dependencies
SERVICE_DEFINITIONS = {
"authority": {
"ports": ["8080:8080"],
"depends_on": ["postgres"],
"environment": {
"AUTHORITY_DB_CONNECTION": "Host=postgres;Database=authority;Username=stellaops;Password=${POSTGRES_PASSWORD}",
},
"healthcheck": {
"test": ["CMD", "curl", "-f", "http://localhost:8080/health"],
"interval": "30s",
"timeout": "10s",
"retries": 3,
},
},
"attestor": {
"ports": ["8081:8080"],
"depends_on": ["postgres", "authority"],
"environment": {
"ATTESTOR_DB_CONNECTION": "Host=postgres;Database=attestor;Username=stellaops;Password=${POSTGRES_PASSWORD}",
"ATTESTOR_AUTHORITY_URL": "http://authority:8080",
},
},
"concelier": {
"ports": ["8082:8080"],
"depends_on": ["postgres", "valkey"],
"environment": {
"CONCELIER_DB_CONNECTION": "Host=postgres;Database=concelier;Username=stellaops;Password=${POSTGRES_PASSWORD}",
"CONCELIER_CACHE_URL": "valkey:6379",
},
},
"scanner": {
"ports": ["8083:8080"],
"depends_on": ["postgres", "concelier"],
"environment": {
"SCANNER_DB_CONNECTION": "Host=postgres;Database=scanner;Username=stellaops;Password=${POSTGRES_PASSWORD}",
"SCANNER_CONCELIER_URL": "http://concelier:8080",
},
"volumes": ["/var/run/docker.sock:/var/run/docker.sock:ro"],
},
"policy": {
"ports": ["8084:8080"],
"depends_on": ["postgres"],
"environment": {
"POLICY_DB_CONNECTION": "Host=postgres;Database=policy;Username=stellaops;Password=${POSTGRES_PASSWORD}",
},
},
"signer": {
"ports": ["8085:8080"],
"depends_on": ["authority"],
"environment": {
"SIGNER_AUTHORITY_URL": "http://authority:8080",
},
},
"excititor": {
"ports": ["8086:8080"],
"depends_on": ["postgres", "concelier"],
"environment": {
"EXCITITOR_DB_CONNECTION": "Host=postgres;Database=excititor;Username=stellaops;Password=${POSTGRES_PASSWORD}",
},
},
"gateway": {
"ports": ["8000:8080"],
"depends_on": ["authority"],
"environment": {
"GATEWAY_AUTHORITY_URL": "http://authority:8080",
},
},
"scheduler": {
"ports": ["8087:8080"],
"depends_on": ["postgres", "valkey"],
"environment": {
"SCHEDULER_DB_CONNECTION": "Host=postgres;Database=scheduler;Username=stellaops;Password=${POSTGRES_PASSWORD}",
"SCHEDULER_QUEUE_URL": "valkey:6379",
},
},
}
# Infrastructure services
INFRASTRUCTURE_SERVICES = {
"postgres": {
"image": "postgres:16-alpine",
"environment": {
"POSTGRES_USER": "stellaops",
"POSTGRES_PASSWORD": "${POSTGRES_PASSWORD:-stellaops}",
"POSTGRES_DB": "stellaops",
},
"volumes": ["postgres_data:/var/lib/postgresql/data"],
"healthcheck": {
"test": ["CMD-SHELL", "pg_isready -U stellaops"],
"interval": "10s",
"timeout": "5s",
"retries": 5,
},
},
"valkey": {
"image": "valkey/valkey:8-alpine",
"volumes": ["valkey_data:/data"],
"healthcheck": {
"test": ["CMD", "valkey-cli", "ping"],
"interval": "10s",
"timeout": "5s",
"retries": 5,
},
},
}
def read_service_versions() -> Dict[str, dict]:
"""Read service versions from manifest."""
if not MANIFEST_FILE.exists():
return {}
try:
manifest = json.loads(MANIFEST_FILE.read_text(encoding="utf-8"))
return manifest.get("services", {})
except json.JSONDecodeError:
return {}
def generate_compose(
version: str,
codename: str,
registry: str,
services: Dict[str, dict],
airgap: bool = False,
include_deps: bool = True,
) -> str:
"""Generate Docker Compose YAML."""
now = datetime.now(timezone.utc)
lines = [
"# Docker Compose for StellaOps Suite",
f"# Version: {version} \"{codename}\"",
f"# Generated: {now.isoformat()}",
"#",
"# Usage:",
"# docker compose up -d",
"# docker compose logs -f",
"# docker compose down",
"#",
"# Environment variables:",
"# POSTGRES_PASSWORD - PostgreSQL password (default: stellaops)",
"#",
"",
"services:",
]
# Add infrastructure services if requested
if include_deps:
for name, config in INFRASTRUCTURE_SERVICES.items():
lines.extend(generate_service_block(name, config, indent=2))
# Add StellaOps services
for svc_name, svc_def in SERVICE_DEFINITIONS.items():
# Get version info from manifest
manifest_info = services.get(svc_name, {})
docker_tag = manifest_info.get("dockerTag") or manifest_info.get("version", version)
# Build image reference
if airgap:
image = f"localhost:5000/{svc_name}:{docker_tag}"
else:
image = f"{registry}/{svc_name}:{docker_tag}"
# Build service config
config = {
"image": image,
"restart": "unless-stopped",
**svc_def,
}
# Add release labels
config["labels"] = {
"com.stellaops.release.version": version,
"com.stellaops.release.codename": codename,
"com.stellaops.service.name": svc_name,
"com.stellaops.service.version": manifest_info.get("version", "1.0.0"),
}
lines.extend(generate_service_block(svc_name, config, indent=2))
# Add volumes
lines.extend([
"",
"volumes:",
])
if include_deps:
lines.extend([
" postgres_data:",
" driver: local",
" valkey_data:",
" driver: local",
])
# Add networks
lines.extend([
"",
"networks:",
" default:",
" name: stellaops",
" driver: bridge",
])
return "\n".join(lines)
def generate_service_block(name: str, config: dict, indent: int = 2) -> List[str]:
"""Generate YAML block for a service."""
prefix = " " * indent
lines = [
"",
f"{prefix}{name}:",
]
inner_prefix = " " * (indent + 2)
# Image
if "image" in config:
lines.append(f"{inner_prefix}image: {config['image']}")
# Container name
lines.append(f"{inner_prefix}container_name: stellaops-{name}")
# Restart policy
if "restart" in config:
lines.append(f"{inner_prefix}restart: {config['restart']}")
# Ports
if "ports" in config:
lines.append(f"{inner_prefix}ports:")
for port in config["ports"]:
lines.append(f"{inner_prefix} - \"{port}\"")
# Volumes
if "volumes" in config:
lines.append(f"{inner_prefix}volumes:")
for vol in config["volumes"]:
lines.append(f"{inner_prefix} - {vol}")
# Environment
if "environment" in config:
lines.append(f"{inner_prefix}environment:")
for key, value in config["environment"].items():
lines.append(f"{inner_prefix} {key}: \"{value}\"")
# Depends on
if "depends_on" in config:
lines.append(f"{inner_prefix}depends_on:")
for dep in config["depends_on"]:
lines.append(f"{inner_prefix} {dep}:")
lines.append(f"{inner_prefix} condition: service_healthy")
# Health check
if "healthcheck" in config:
hc = config["healthcheck"]
lines.append(f"{inner_prefix}healthcheck:")
if "test" in hc:
test = hc["test"]
if isinstance(test, list):
lines.append(f"{inner_prefix} test: {json.dumps(test)}")
else:
lines.append(f"{inner_prefix} test: \"{test}\"")
for key in ["interval", "timeout", "retries", "start_period"]:
if key in hc:
lines.append(f"{inner_prefix} {key}: {hc[key]}")
# Labels
if "labels" in config:
lines.append(f"{inner_prefix}labels:")
for key, value in config["labels"].items():
lines.append(f"{inner_prefix} {key}: \"{value}\"")
return lines
def main():
parser = argparse.ArgumentParser(
description="Generate pinned Docker Compose files for suite releases",
)
parser.add_argument("version", help="Suite version (YYYY.MM format)")
parser.add_argument("codename", help="Release codename")
parser.add_argument("--output", "-o", help="Output file")
parser.add_argument(
"--airgap",
action="store_true",
help="Generate air-gap variant (localhost:5000 registry)",
)
parser.add_argument(
"--registry",
default=DEFAULT_REGISTRY,
help="Container registry URL",
)
parser.add_argument(
"--include-deps",
action="store_true",
default=True,
help="Include infrastructure dependencies",
)
parser.add_argument(
"--no-deps",
action="store_true",
help="Exclude infrastructure dependencies",
)
args = parser.parse_args()
# Read service versions
services = read_service_versions()
if not services:
print("Warning: No service versions found in manifest", file=sys.stderr)
# Generate compose file
include_deps = args.include_deps and not args.no_deps
compose = generate_compose(
version=args.version,
codename=args.codename,
registry=args.registry,
services=services,
airgap=args.airgap,
include_deps=include_deps,
)
# Output
if args.output:
Path(args.output).write_text(compose, encoding="utf-8")
print(f"Docker Compose written to: {args.output}", file=sys.stderr)
else:
print(compose)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,477 @@
#!/usr/bin/env python3
"""
generate_suite_docs.py - Generate suite release documentation
Sprint: CI/CD Enhancement - Suite Release Pipeline
Creates the docs/releases/YYYY.MM/ documentation structure.
Usage:
python generate_suite_docs.py <version> <codename> [options]
python generate_suite_docs.py 2026.04 Nova --channel lts
python generate_suite_docs.py 2026.10 Orion --changelog CHANGELOG.md
Arguments:
version Suite version (YYYY.MM format)
codename Release codename
Options:
--channel CH Release channel: edge, stable, lts
--changelog FILE Pre-generated changelog file
--output-dir DIR Output directory (default: docs/releases/YYYY.MM)
--registry URL Container registry URL
--previous VERSION Previous version for upgrade guide
"""
import argparse
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
# Repository paths
SCRIPT_DIR = Path(__file__).parent
REPO_ROOT = SCRIPT_DIR.parent.parent.parent
VERSIONS_FILE = REPO_ROOT / "src" / "Directory.Versions.props"
MANIFEST_FILE = REPO_ROOT / "devops" / "releases" / "service-versions.json"
# Default registry
DEFAULT_REGISTRY = "git.stella-ops.org/stella-ops.org"
# Support timeline
SUPPORT_TIMELINE = {
"edge": "3 months",
"stable": "9 months",
"lts": "5 years",
}
def get_git_sha() -> str:
"""Get current git HEAD SHA."""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
cwd=REPO_ROOT,
check=True,
)
return result.stdout.strip()[:12]
except subprocess.CalledProcessError:
return "unknown"
def read_service_versions() -> Dict[str, dict]:
"""Read service versions from manifest."""
if not MANIFEST_FILE.exists():
return {}
try:
manifest = json.loads(MANIFEST_FILE.read_text(encoding="utf-8"))
return manifest.get("services", {})
except json.JSONDecodeError:
return {}
def generate_readme(
version: str,
codename: str,
channel: str,
registry: str,
services: Dict[str, dict],
) -> str:
"""Generate README.md for the release."""
now = datetime.now(timezone.utc)
support_period = SUPPORT_TIMELINE.get(channel, "unknown")
lines = [
f"# StellaOps {version} \"{codename}\"",
"",
f"**Release Date:** {now.strftime('%B %d, %Y')}",
f"**Channel:** {channel.upper()}",
f"**Support Period:** {support_period}",
"",
"## Overview",
"",
f"StellaOps {version} \"{codename}\" is a {'Long-Term Support (LTS)' if channel == 'lts' else channel} release ",
"of the StellaOps container security platform.",
"",
"## Quick Start",
"",
"### Docker Compose",
"",
"```bash",
f"curl -O https://git.stella-ops.org/stella-ops.org/releases/{version}/docker-compose.yml",
"docker compose up -d",
"```",
"",
"### Helm",
"",
"```bash",
f"helm repo add stellaops https://charts.stella-ops.org",
f"helm install stellaops stellaops/stellaops --version {version}",
"```",
"",
"## Included Services",
"",
"| Service | Version | Image |",
"|---------|---------|-------|",
]
for key, svc in sorted(services.items()):
name = svc.get("name", key.title())
ver = svc.get("version", "1.0.0")
tag = svc.get("dockerTag", ver)
image = f"`{registry}/{key}:{tag}`"
lines.append(f"| {name} | {ver} | {image} |")
lines.extend([
"",
"## Documentation",
"",
"- [CHANGELOG.md](./CHANGELOG.md) - Detailed list of changes",
"- [services.md](./services.md) - Service version details",
"- [upgrade-guide.md](./upgrade-guide.md) - Upgrade instructions",
"- [docker-compose.yml](./docker-compose.yml) - Docker Compose configuration",
"",
"## Support",
"",
f"This release is supported until **{calculate_eol(now, channel)}**.",
"",
"For issues and feature requests, please visit:",
"https://git.stella-ops.org/stella-ops.org/git.stella-ops.org/issues",
"",
"---",
"",
f"Generated: {now.isoformat()}",
f"Git SHA: {get_git_sha()}",
])
return "\n".join(lines)
def calculate_eol(release_date: datetime, channel: str) -> str:
"""Calculate end-of-life date based on channel."""
from dateutil.relativedelta import relativedelta
periods = {
"edge": relativedelta(months=3),
"stable": relativedelta(months=9),
"lts": relativedelta(years=5),
}
try:
eol = release_date + periods.get(channel, relativedelta(months=9))
return eol.strftime("%B %Y")
except ImportError:
# Fallback without dateutil
return f"See {channel} support policy"
def generate_services_doc(
version: str,
codename: str,
registry: str,
services: Dict[str, dict],
) -> str:
"""Generate services.md with detailed service information."""
lines = [
f"# Services - StellaOps {version} \"{codename}\"",
"",
"This document lists all services included in this release with their versions,",
"Docker images, and configuration details.",
"",
"## Service Matrix",
"",
"| Service | Version | Docker Tag | Released | Git SHA |",
"|---------|---------|------------|----------|---------|",
]
for key, svc in sorted(services.items()):
name = svc.get("name", key.title())
ver = svc.get("version", "1.0.0")
tag = svc.get("dockerTag") or "-"
released = svc.get("releasedAt", "-")
if released != "-":
released = released[:10]
sha = svc.get("gitSha") or "-"
lines.append(f"| {name} | {ver} | `{tag}` | {released} | `{sha}` |")
lines.extend([
"",
"## Container Images",
"",
"All images are available from the StellaOps registry:",
"",
"```",
f"Registry: {registry}",
"```",
"",
"### Pull Commands",
"",
"```bash",
])
for key, svc in sorted(services.items()):
tag = svc.get("dockerTag") or svc.get("version", "latest")
lines.append(f"docker pull {registry}/{key}:{tag}")
lines.extend([
"```",
"",
"## Service Descriptions",
"",
])
service_descriptions = {
"authority": "Authentication and authorization service with OAuth/OIDC support",
"attestor": "in-toto/DSSE attestation generation and verification",
"concelier": "Vulnerability advisory ingestion and merge engine",
"scanner": "Container scanning with SBOM generation",
"policy": "Policy engine with K4 lattice logic",
"signer": "Cryptographic signing operations",
"excititor": "VEX document ingestion and export",
"gateway": "API gateway with routing and transport abstraction",
"scheduler": "Job scheduling and queue management",
"cli": "Command-line interface",
"orchestrator": "Workflow orchestration and task coordination",
"notify": "Notification delivery (Email, Slack, Teams, Webhooks)",
}
for key, svc in sorted(services.items()):
name = svc.get("name", key.title())
desc = service_descriptions.get(key, "StellaOps service")
lines.extend([
f"### {name}",
"",
desc,
"",
f"- **Version:** {svc.get('version', '1.0.0')}",
f"- **Image:** `{registry}/{key}:{svc.get('dockerTag', 'latest')}`",
"",
])
return "\n".join(lines)
def generate_upgrade_guide(
version: str,
codename: str,
previous_version: Optional[str],
) -> str:
"""Generate upgrade-guide.md."""
lines = [
f"# Upgrade Guide - StellaOps {version} \"{codename}\"",
"",
]
if previous_version:
lines.extend([
f"This guide covers upgrading from StellaOps {previous_version} to {version}.",
"",
])
else:
lines.extend([
"This guide covers upgrading to this release from a previous version.",
"",
])
lines.extend([
"## Before You Begin",
"",
"1. **Backup your data** - Ensure all databases and configuration are backed up",
"2. **Review changelog** - Check [CHANGELOG.md](./CHANGELOG.md) for breaking changes",
"3. **Check compatibility** - Verify your environment meets the requirements",
"",
"## Upgrade Steps",
"",
"### Docker Compose",
"",
"```bash",
"# Pull new images",
"docker compose pull",
"",
"# Stop services",
"docker compose down",
"",
"# Start with new version",
"docker compose up -d",
"",
"# Verify health",
"docker compose ps",
"```",
"",
"### Helm",
"",
"```bash",
"# Update repository",
"helm repo update stellaops",
"",
"# Upgrade release",
f"helm upgrade stellaops stellaops/stellaops --version {version}",
"",
"# Verify status",
"helm status stellaops",
"```",
"",
"## Database Migrations",
"",
"Database migrations are applied automatically on service startup.",
"For manual migration control, set `AUTO_MIGRATE=false` and run:",
"",
"```bash",
"stellaops-cli db migrate",
"```",
"",
"## Configuration Changes",
"",
"Review the following configuration changes:",
"",
"| Setting | Previous | New | Notes |",
"|---------|----------|-----|-------|",
"| (No breaking changes) | - | - | - |",
"",
"## Rollback Procedure",
"",
"If issues occur, rollback to the previous version:",
"",
"### Docker Compose",
"",
"```bash",
"# Edit docker-compose.yml to use previous image tags",
"docker compose down",
"docker compose up -d",
"```",
"",
"### Helm",
"",
"```bash",
"helm rollback stellaops",
"```",
"",
"## Support",
"",
"For upgrade assistance, contact support or open an issue at:",
"https://git.stella-ops.org/stella-ops.org/git.stella-ops.org/issues",
])
return "\n".join(lines)
def generate_manifest_yaml(
version: str,
codename: str,
channel: str,
services: Dict[str, dict],
) -> str:
"""Generate manifest.yaml for the release."""
now = datetime.now(timezone.utc)
lines = [
"apiVersion: stellaops.org/v1",
"kind: SuiteRelease",
"metadata:",
f" version: \"{version}\"",
f" codename: \"{codename}\"",
f" channel: \"{channel}\"",
f" date: \"{now.isoformat()}\"",
f" gitSha: \"{get_git_sha()}\"",
"spec:",
" services:",
]
for key, svc in sorted(services.items()):
lines.append(f" {key}:")
lines.append(f" version: \"{svc.get('version', '1.0.0')}\"")
if svc.get("dockerTag"):
lines.append(f" dockerTag: \"{svc['dockerTag']}\"")
if svc.get("gitSha"):
lines.append(f" gitSha: \"{svc['gitSha']}\"")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Generate suite release documentation",
)
parser.add_argument("version", help="Suite version (YYYY.MM format)")
parser.add_argument("codename", help="Release codename")
parser.add_argument(
"--channel",
choices=["edge", "stable", "lts"],
default="stable",
help="Release channel",
)
parser.add_argument("--changelog", help="Pre-generated changelog file")
parser.add_argument("--output-dir", help="Output directory")
parser.add_argument(
"--registry",
default=DEFAULT_REGISTRY,
help="Container registry URL",
)
parser.add_argument("--previous", help="Previous version for upgrade guide")
args = parser.parse_args()
# Determine output directory
if args.output_dir:
output_dir = Path(args.output_dir)
else:
output_dir = REPO_ROOT / "docs" / "releases" / args.version
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Output directory: {output_dir}", file=sys.stderr)
# Read service versions
services = read_service_versions()
if not services:
print("Warning: No service versions found in manifest", file=sys.stderr)
# Generate README.md
readme = generate_readme(
args.version, args.codename, args.channel, args.registry, services
)
(output_dir / "README.md").write_text(readme, encoding="utf-8")
print("Generated: README.md", file=sys.stderr)
# Copy or generate CHANGELOG.md
if args.changelog and Path(args.changelog).exists():
changelog = Path(args.changelog).read_text(encoding="utf-8")
else:
# Generate basic changelog
changelog = f"# Changelog - StellaOps {args.version} \"{args.codename}\"\n\n"
changelog += "See git history for detailed changes.\n"
(output_dir / "CHANGELOG.md").write_text(changelog, encoding="utf-8")
print("Generated: CHANGELOG.md", file=sys.stderr)
# Generate services.md
services_doc = generate_services_doc(
args.version, args.codename, args.registry, services
)
(output_dir / "services.md").write_text(services_doc, encoding="utf-8")
print("Generated: services.md", file=sys.stderr)
# Generate upgrade-guide.md
upgrade_guide = generate_upgrade_guide(
args.version, args.codename, args.previous
)
(output_dir / "upgrade-guide.md").write_text(upgrade_guide, encoding="utf-8")
print("Generated: upgrade-guide.md", file=sys.stderr)
# Generate manifest.yaml
manifest = generate_manifest_yaml(
args.version, args.codename, args.channel, services
)
(output_dir / "manifest.yaml").write_text(manifest, encoding="utf-8")
print("Generated: manifest.yaml", file=sys.stderr)
print(f"\nSuite documentation generated in: {output_dir}", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,131 @@
#!/bin/bash
# read-service-version.sh - Read service version from centralized storage
#
# Sprint: CI/CD Enhancement - Per-Service Auto-Versioning
# This script reads service versions from src/Directory.Versions.props
#
# Usage:
# ./read-service-version.sh <service>
# ./read-service-version.sh authority
# ./read-service-version.sh --all
#
# Output:
# Prints the version string to stdout (e.g., "1.2.3")
# Exit code 0 on success, 1 on error
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
VERSIONS_FILE="${REPO_ROOT}/src/Directory.Versions.props"
# Service name to property suffix mapping
declare -A SERVICE_MAP=(
["authority"]="Authority"
["attestor"]="Attestor"
["concelier"]="Concelier"
["scanner"]="Scanner"
["policy"]="Policy"
["signer"]="Signer"
["excititor"]="Excititor"
["gateway"]="Gateway"
["scheduler"]="Scheduler"
["cli"]="Cli"
["orchestrator"]="Orchestrator"
["notify"]="Notify"
["sbomservice"]="SbomService"
["vexhub"]="VexHub"
["evidencelocker"]="EvidenceLocker"
)
usage() {
cat << EOF
Usage: $(basename "$0") <service|--all>
Read service version from centralized version storage.
Arguments:
service Service name (authority, attestor, concelier, scanner, etc.)
--all Print all service versions in JSON format
Services:
${!SERVICE_MAP[*]}
Examples:
$(basename "$0") authority # Output: 1.0.0
$(basename "$0") scanner # Output: 1.2.3
$(basename "$0") --all # Output: {"authority":"1.0.0",...}
EOF
}
read_version() {
local service="$1"
local property_suffix="${SERVICE_MAP[$service]:-}"
if [[ -z "$property_suffix" ]]; then
echo "Error: Unknown service '$service'" >&2
echo "Valid services: ${!SERVICE_MAP[*]}" >&2
return 1
fi
if [[ ! -f "$VERSIONS_FILE" ]]; then
echo "Error: Versions file not found: $VERSIONS_FILE" >&2
return 1
fi
local property_name="StellaOps${property_suffix}Version"
local version
version=$(grep -oP "<${property_name}>\K[0-9]+\.[0-9]+\.[0-9]+" "$VERSIONS_FILE" || true)
if [[ -z "$version" ]]; then
echo "Error: Property '$property_name' not found in $VERSIONS_FILE" >&2
return 1
fi
echo "$version"
}
read_all_versions() {
if [[ ! -f "$VERSIONS_FILE" ]]; then
echo "Error: Versions file not found: $VERSIONS_FILE" >&2
return 1
fi
echo -n "{"
local first=true
for service in "${!SERVICE_MAP[@]}"; do
local version
version=$(read_version "$service" 2>/dev/null || echo "")
if [[ -n "$version" ]]; then
if [[ "$first" != "true" ]]; then
echo -n ","
fi
echo -n "\"$service\":\"$version\""
first=false
fi
done
echo "}"
}
main() {
if [[ $# -eq 0 ]]; then
usage
exit 1
fi
case "$1" in
--help|-h)
usage
exit 0
;;
--all)
read_all_versions
;;
*)
read_version "$1"
;;
esac
}
main "$@"

View File

@@ -0,0 +1,226 @@
#!/usr/bin/env bash
set -euo pipefail
# Rollback Script
# Sprint: CI/CD Enhancement - Deployment Safety
#
# Purpose: Execute rollback to a previous version
# Usage:
# ./rollback.sh --environment <env> --version <ver> --services <json> --reason <text>
#
# Exit codes:
# 0 - Rollback successful
# 1 - General error
# 2 - Invalid arguments
# 3 - Deployment failed
# 4 - Health check failed
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() {
echo -e "${GREEN}[INFO]${NC} $*"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $*"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $*" >&2
}
log_step() {
echo -e "${BLUE}[STEP]${NC} $*"
}
usage() {
cat << EOF
Usage: $(basename "$0") [OPTIONS]
Execute rollback to a previous version.
Options:
--environment <env> Target environment (staging|production)
--version <version> Target version to rollback to
--services <json> JSON array of services to rollback
--reason <text> Reason for rollback
--dry-run Show what would be done without executing
--help, -h Show this help message
Examples:
$(basename "$0") --environment staging --version 1.2.3 --services '["scanner"]' --reason "Bug fix"
$(basename "$0") --environment production --version 1.2.0 --services '["authority","scanner"]' --reason "Hotfix rollback"
Exit codes:
0 Rollback successful
1 General error
2 Invalid arguments
3 Deployment failed
4 Health check failed
EOF
}
# Default values
ENVIRONMENT=""
VERSION=""
SERVICES=""
REASON=""
DRY_RUN=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--environment)
ENVIRONMENT="$2"
shift 2
;;
--version)
VERSION="$2"
shift 2
;;
--services)
SERVICES="$2"
shift 2
;;
--reason)
REASON="$2"
shift 2
;;
--dry-run)
DRY_RUN=true
shift
;;
--help|-h)
usage
exit 0
;;
*)
log_error "Unknown option: $1"
usage
exit 2
;;
esac
done
# Validate required arguments
if [[ -z "$ENVIRONMENT" ]] || [[ -z "$VERSION" ]] || [[ -z "$SERVICES" ]]; then
log_error "Missing required arguments"
usage
exit 2
fi
# Validate environment
if [[ "$ENVIRONMENT" != "staging" ]] && [[ "$ENVIRONMENT" != "production" ]]; then
log_error "Invalid environment: $ENVIRONMENT (must be staging or production)"
exit 2
fi
# Validate services JSON
if ! echo "$SERVICES" | jq empty 2>/dev/null; then
log_error "Invalid services JSON: $SERVICES"
exit 2
fi
log_info "Starting rollback process"
log_info " Environment: $ENVIRONMENT"
log_info " Version: $VERSION"
log_info " Services: $SERVICES"
log_info " Reason: $REASON"
log_info " Dry run: $DRY_RUN"
# Record start time
START_TIME=$(date +%s)
# Rollback each service
FAILED_SERVICES=()
SUCCESSFUL_SERVICES=()
echo "$SERVICES" | jq -r '.[]' | while read -r service; do
log_step "Rolling back $service to $VERSION..."
if [[ "$DRY_RUN" == "true" ]]; then
log_info " [DRY RUN] Would rollback $service"
continue
fi
# Determine deployment method
HELM_RELEASE="stellaops-${service}"
NAMESPACE="stellaops-${ENVIRONMENT}"
# Check if Helm release exists
if helm status "$HELM_RELEASE" -n "$NAMESPACE" >/dev/null 2>&1; then
log_info " Using Helm rollback for $service"
# Get revision for target version
REVISION=$(helm history "$HELM_RELEASE" -n "$NAMESPACE" --output json | \
jq -r --arg ver "$VERSION" '.[] | select(.app_version == $ver) | .revision' | tail -1)
if [[ -n "$REVISION" ]]; then
if helm rollback "$HELM_RELEASE" "$REVISION" -n "$NAMESPACE" --wait --timeout 5m; then
log_info " Successfully rolled back $service to revision $REVISION"
SUCCESSFUL_SERVICES+=("$service")
else
log_error " Failed to rollback $service"
FAILED_SERVICES+=("$service")
fi
else
log_warn " No Helm revision found for version $VERSION"
log_info " Attempting deployment with specific version..."
# Try to deploy specific version
IMAGE_TAG="${VERSION}"
VALUES_FILE="${REPO_ROOT}/devops/helm/values-${ENVIRONMENT}.yaml"
if helm upgrade "$HELM_RELEASE" "${REPO_ROOT}/devops/helm/stellaops" \
-n "$NAMESPACE" \
--set "services.${service}.image.tag=${IMAGE_TAG}" \
-f "$VALUES_FILE" \
--wait --timeout 5m 2>/dev/null; then
log_info " Deployed $service with version $VERSION"
SUCCESSFUL_SERVICES+=("$service")
else
log_error " Failed to deploy $service with version $VERSION"
FAILED_SERVICES+=("$service")
fi
fi
else
log_warn " No Helm release found for $service"
log_info " Attempting kubectl rollout undo..."
DEPLOYMENT="stellaops-${service}"
if kubectl rollout undo deployment/"$DEPLOYMENT" -n "$NAMESPACE" 2>/dev/null; then
log_info " Rolled back deployment $DEPLOYMENT"
SUCCESSFUL_SERVICES+=("$service")
else
log_error " Failed to rollback deployment $DEPLOYMENT"
FAILED_SERVICES+=("$service")
fi
fi
done
# Calculate duration
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
# Summary
echo ""
log_info "Rollback completed in ${DURATION}s"
log_info " Successful: ${#SUCCESSFUL_SERVICES[@]}"
log_info " Failed: ${#FAILED_SERVICES[@]}"
if [[ ${#FAILED_SERVICES[@]} -gt 0 ]]; then
log_error "Failed services: ${FAILED_SERVICES[*]}"
exit 3
fi
log_info "Rollback successful"
exit 0