provisioning/tools/release/create-release.nu
2025-10-07 11:12:02 +01:00

792 lines
25 KiB
Plaintext

#!/usr/bin/env nu
# Release creation tool - creates GitHub releases with automated changelog
#
# Creates:
# - Git tags with proper versioning
# - GitHub releases with generated changelogs
# - Release notes from commit history
# - Asset uploads and management
# - Version bumping automation
use std log
def main [
--version: string # Release version (e.g., 2.1.0, auto-increment if empty)
--release-type: string = "patch" # Release type: major, minor, patch, pre-release
--pre-release: bool = false # Mark as pre-release
--draft: bool = false # Create as draft release
--generate-changelog: bool = true # Generate changelog from commits
--asset-dir: string = "packages" # Directory containing release assets
--dry-run: bool = false # Show what would be created without doing it
--push-tag: bool = true # Push git tag to remote
--auto-upload: bool = true # Automatically upload assets
--verbose: bool = false # Enable verbose logging
] -> record {
let repo_root = ($env.PWD | path dirname | path dirname | path dirname)
# Determine release version
let target_version = if $version == "" {
determine_next_version $repo_root $release_type
} else {
$version
}
let release_config = {
version: $target_version
release_type: $release_type
pre_release: $pre_release
draft: $draft
generate_changelog: $generate_changelog
asset_dir: ($asset_dir | path expand)
dry_run: $dry_run
push_tag: $push_tag
auto_upload: $auto_upload
verbose: $verbose
repo_root: $repo_root
}
log info $"Starting release creation with config: ($release_config)"
# Validate repository state
let repo_validation = validate_repository_state $repo_root $release_config
if $repo_validation.status != "ready" {
log error $"Repository not ready for release: ($repo_validation.reason)"
exit 1
}
let release_results = []
try {
# Generate changelog if requested
let changelog_result = if $release_config.generate_changelog {
generate_changelog $repo_root $release_config
} else {
{ status: "skipped", content: "", commits: [] }
}
let release_results = ($release_results | append { step: "changelog", result: $changelog_result })
# Create git tag
let tag_result = create_git_tag $repo_root $release_config
let release_results = ($release_results | append { step: "git_tag", result: $tag_result })
if $tag_result.status != "success" and not $release_config.dry_run {
log error $"Failed to create git tag: ($tag_result.reason)"
exit 1
}
# Push tag if requested
let push_result = if $release_config.push_tag and not $release_config.dry_run {
push_git_tag $repo_root $release_config
} else {
{ status: "skipped", reason: "push disabled or dry run" }
}
let release_results = ($release_results | append { step: "push_tag", result: $push_result })
# Create GitHub release
let github_result = create_github_release $repo_root $release_config $changelog_result
let release_results = ($release_results | append { step: "github_release", result: $github_result })
if $github_result.status != "success" and not $release_config.dry_run {
log error $"Failed to create GitHub release: ($github_result.reason)"
exit 1
}
# Upload assets if requested
let upload_result = if $release_config.auto_upload and not $release_config.dry_run {
upload_release_assets $repo_root $release_config $github_result
} else {
{ status: "skipped", reason: "auto-upload disabled or dry run", assets: [] }
}
let release_results = ($release_results | append { step: "upload_assets", result: $upload_result })
# Update version in project files
let version_update_result = update_project_version $repo_root $release_config
let release_results = ($release_results | append { step: "version_update", result: $version_update_result })
let summary = {
version: $release_config.version
release_type: $release_config.release_type
success: true
github_url: $github_result.release_url
tag_name: $"v($release_config.version)"
changelog_commits: ($changelog_result.commits | length)
uploaded_assets: ($upload_result.assets | length)
release_config: $release_config
steps: $release_results
}
if $release_config.dry_run {
log info $"Dry run completed - would create release v($release_config.version)"
} else {
log info $"Release v($release_config.version) created successfully: ($github_result.release_url)"
}
return $summary
} catch {|err|
log error $"Release creation failed: ($err.msg)"
# Cleanup on failure if not dry run
if not $release_config.dry_run {
cleanup_failed_release $repo_root $release_config
}
exit 1
}
}
# Determine next version based on current version and release type
def determine_next_version [repo_root: string, release_type: string] -> string {
cd $repo_root
# Get latest tag
let latest_tag = try {
git describe --tags --abbrev=0 2>/dev/null | str trim
} catch {
"v0.0.0"
}
# Parse version from tag (remove 'v' prefix if present)
let current_version = ($latest_tag | str replace "^v" "")
let version_parts = ($current_version | split row ".")
if ($version_parts | length) < 3 {
log warning $"Invalid version format: ($current_version), using 0.0.0"
return "0.1.0"
}
let major = ($version_parts | get 0 | into int)
let minor = ($version_parts | get 1 | into int)
let patch = ($version_parts | get 2 | into int)
match $release_type {
"major" => { $"($major + 1).0.0" }
"minor" => { $"($major).($minor + 1).0" }
"patch" => { $"($major).($minor).($patch + 1)" }
"pre-release" => { $"($major).($minor).($patch + 1)-rc1" }
_ => {
log warning $"Unknown release type: ($release_type), using patch"
$"($major).($minor).($patch + 1)"
}
}
}
# Validate repository state before creating release
def validate_repository_state [repo_root: string, release_config: record] -> record {
cd $repo_root
# Check if we're in a git repository
if not (".git" | path exists) {
return { status: "failed", reason: "not in a git repository" }
}
# Check for uncommitted changes
let status_result = (git status --porcelain | complete)
if $status_result.exit_code == 0 and ($status_result.stdout | str trim) != "" {
return { status: "failed", reason: "uncommitted changes in working directory" }
}
# Check if current branch is main/master
let current_branch = try {
git branch --show-current | str trim
} catch {
"unknown"
}
if not ($current_branch in ["main", "master"]) {
return {
status: "warning",
reason: $"not on main branch (current: ($current_branch))",
current_branch: $current_branch
}
}
# Check if we can push to remote
let remote_check = try {
git remote -v | complete
} catch {
{ exit_code: 1, stdout: "" }
}
if $remote_check.exit_code != 0 or ($remote_check.stdout | str trim) == "" {
return { status: "warning", reason: "no remote repository configured" }
}
# Check if GitHub CLI is available (for GitHub releases)
let gh_check = try {
gh --version | complete
} catch {
{ exit_code: 1 }
}
if $gh_check.exit_code != 0 {
return { status: "warning", reason: "GitHub CLI (gh) not available - manual release creation required" }
}
return { status: "ready", current_branch: $current_branch }
}
# Generate changelog from commit history
def generate_changelog [repo_root: string, release_config: record] -> record {
log info "Generating changelog from commit history..."
cd $repo_root
try {
# Get last release tag
let last_tag = try {
git describe --tags --abbrev=0 2>/dev/null | str trim
} catch {
# If no tags, get all commits
""
}
# Get commits since last release
let git_log_cmd = if $last_tag != "" {
$"git log ($last_tag)..HEAD --pretty=format:\"%h|%s|%an|%ad\" --date=short"
} else {
$"git log --pretty=format:\"%h|%s|%an|%ad\" --date=short"
}
let commit_lines = (bash -c $git_log_cmd | lines | where $it != "")
# Parse commits
let commits = $commit_lines | each {|line|
let parts = ($line | split column "|" hash subject author date)
if ($parts | length) >= 4 {
{
hash: $parts.hash
subject: $parts.subject
author: $parts.author
date: $parts.date
type: (classify_commit_type $parts.subject)
}
} else {
null
}
} | where $it != null
# Group commits by type
let grouped_commits = ($commits | group-by type)
# Generate changelog content
let changelog_content = format_changelog $grouped_commits $release_config
{
status: "success"
content: $changelog_content
commits: $commits
commit_count: ($commits | length)
since_tag: $last_tag
}
} catch {|err|
{
status: "failed"
reason: $err.msg
content: ""
commits: []
}
}
}
# Classify commit type based on conventional commits
def classify_commit_type [subject: string] -> string {
if ($subject =~ "^feat(\\(.+\\))?:") {
"features"
} else if ($subject =~ "^fix(\\(.+\\))?:") {
"bug_fixes"
} else if ($subject =~ "^docs(\\(.+\\))?:") {
"documentation"
} else if ($subject =~ "^refactor(\\(.+\\))?:") {
"refactoring"
} else if ($subject =~ "^test(\\(.+\\))?:") {
"testing"
} else if ($subject =~ "^chore(\\(.+\\))?:") {
"maintenance"
} else if ($subject =~ "^perf(\\(.+\\))?:") {
"performance"
} else if ($subject =~ "^ci(\\(.+\\))?:") {
"ci_cd"
} else {
"other"
}
}
# Format changelog content
def format_changelog [grouped_commits: record, release_config: record] -> string {
let mut changelog = []
$changelog = ($changelog | append $"# Release v($release_config.version)")
$changelog = ($changelog | append "")
$changelog = ($changelog | append $"**Release Date:** (date now | format date '%Y-%m-%d')")
$changelog = ($changelog | append "")
# Features
if "features" in ($grouped_commits | columns) {
$changelog = ($changelog | append "## 🚀 New Features")
$changelog = ($changelog | append "")
for commit in ($grouped_commits.features) {
$changelog = ($changelog | append $"- ($commit.subject) (($commit.hash))")
}
$changelog = ($changelog | append "")
}
# Bug Fixes
if "bug_fixes" in ($grouped_commits | columns) {
$changelog = ($changelog | append "## 🐛 Bug Fixes")
$changelog = ($changelog | append "")
for commit in ($grouped_commits.bug_fixes) {
$changelog = ($changelog | append $"- ($commit.subject) (($commit.hash))")
}
$changelog = ($changelog | append "")
}
# Performance Improvements
if "performance" in ($grouped_commits | columns) {
$changelog = ($changelog | append "## ⚡ Performance Improvements")
$changelog = ($changelog | append "")
for commit in ($grouped_commits.performance) {
$changelog = ($changelog | append $"- ($commit.subject) (($commit.hash))")
}
$changelog = ($changelog | append "")
}
# Refactoring
if "refactoring" in ($grouped_commits | columns) {
$changelog = ($changelog | append "## 🔧 Refactoring")
$changelog = ($changelog | append "")
for commit in ($grouped_commits.refactoring) {
$changelog = ($changelog | append $"- ($commit.subject) (($commit.hash))")
}
$changelog = ($changelog | append "")
}
# Documentation
if "documentation" in ($grouped_commits | columns) {
$changelog = ($changelog | append "## 📚 Documentation")
$changelog = ($changelog | append "")
for commit in ($grouped_commits.documentation) {
$changelog = ($changelog | append $"- ($commit.subject) (($commit.hash))")
}
$changelog = ($changelog | append "")
}
# Maintenance
if "maintenance" in ($grouped_commits | columns) {
$changelog = ($changelog | append "## 🧹 Maintenance")
$changelog = ($changelog | append "")
for commit in ($grouped_commits.maintenance) {
$changelog = ($changelog | append $"- ($commit.subject) (($commit.hash))")
}
$changelog = ($changelog | append "")
}
# Other changes
if "other" in ($grouped_commits | columns) {
$changelog = ($changelog | append "## 📝 Other Changes")
$changelog = ($changelog | append "")
for commit in ($grouped_commits.other) {
$changelog = ($changelog | append $"- ($commit.subject) (($commit.hash))")
}
$changelog = ($changelog | append "")
}
return ($changelog | str join "\n")
}
# Create git tag
def create_git_tag [repo_root: string, release_config: record] -> record {
log info $"Creating git tag: v($release_config.version)"
cd $repo_root
if $release_config.dry_run {
return {
status: "success"
tag_name: $"v($release_config.version)"
dry_run: true
}
}
try {
let tag_name = $"v($release_config.version)"
# Check if tag already exists
let tag_exists = try {
git tag -l $tag_name | complete
} catch {
{ exit_code: 1, stdout: "" }
}
if $tag_exists.exit_code == 0 and ($tag_exists.stdout | str trim) != "" {
return {
status: "failed"
reason: $"tag ($tag_name) already exists"
tag_name: $tag_name
}
}
# Create annotated tag
let tag_message = $"Release v($release_config.version)"
git tag -a $tag_name -m $tag_message
{
status: "success"
tag_name: $tag_name
message: $tag_message
}
} catch {|err|
{
status: "failed"
reason: $err.msg
tag_name: $"v($release_config.version)"
}
}
}
# Push git tag to remote
def push_git_tag [repo_root: string, release_config: record] -> record {
log info $"Pushing git tag: v($release_config.version)"
cd $repo_root
try {
let tag_name = $"v($release_config.version)"
git push origin $tag_name
{
status: "success"
tag_name: $tag_name
pushed_to: "origin"
}
} catch {|err|
{
status: "failed"
reason: $err.msg
tag_name: $"v($release_config.version)"
}
}
}
# Create GitHub release
def create_github_release [
repo_root: string
release_config: record
changelog_result: record
] -> record {
log info $"Creating GitHub release: v($release_config.version)"
cd $repo_root
if $release_config.dry_run {
return {
status: "success"
release_url: $"https://github.com/owner/repo/releases/tag/v($release_config.version)"
dry_run: true
}
}
try {
let tag_name = $"v($release_config.version)"
let release_title = $"Release v($release_config.version)"
# Prepare release notes
let release_notes = if $changelog_result.status == "success" {
$changelog_result.content
} else {
$"Release v($release_config.version)\n\nChanges in this release:\n- Bug fixes and improvements"
}
# Save release notes to temporary file
let notes_file = ($repo_root | path join "tmp-release-notes.md")
$release_notes | save $notes_file
# Build gh release create command
let mut gh_cmd = ["gh", "release", "create", $tag_name, "--title", $release_title, "--notes-file", $notes_file]
if $release_config.pre_release {
$gh_cmd = ($gh_cmd | append "--prerelease")
}
if $release_config.draft {
$gh_cmd = ($gh_cmd | append "--draft")
}
# Create release
let release_result = (run-external --redirect-combine $gh_cmd.0 ...$gh_cmd.1.. | complete)
# Clean up temporary file
rm $notes_file
if $release_result.exit_code == 0 {
# Get release URL
let release_url = ($release_result.stdout | str trim)
{
status: "success"
release_url: $release_url
tag_name: $tag_name
title: $release_title
pre_release: $release_config.pre_release
draft: $release_config.draft
}
} else {
{
status: "failed"
reason: $release_result.stderr
tag_name: $tag_name
}
}
} catch {|err|
{
status: "failed"
reason: $err.msg
tag_name: $"v($release_config.version)"
}
}
}
# Upload release assets
def upload_release_assets [
repo_root: string
release_config: record
github_result: record
] -> record {
log info "Uploading release assets..."
if not ($release_config.asset_dir | path exists) {
return {
status: "skipped"
reason: "asset directory does not exist"
assets: []
}
}
cd $repo_root
try {
# Find assets to upload
let asset_patterns = ["*.tar.gz", "*.zip", "*.deb", "*.rpm", "*.msi", "*.dmg", "checksums.txt", "manifest.json"]
let mut assets = []
for pattern in $asset_patterns {
let found_assets = (find $release_config.asset_dir -name $pattern -type f)
$assets = ($assets | append $found_assets)
}
$assets = ($assets | uniq)
if ($assets | length) == 0 {
return {
status: "skipped"
reason: "no assets found to upload"
assets: []
}
}
let tag_name = $"v($release_config.version)"
let mut upload_results = []
# Upload each asset
for asset in $assets {
try {
log info $"Uploading asset: ($asset | path basename)"
let upload_result = (gh release upload $tag_name $asset | complete)
if $upload_result.exit_code == 0 {
$upload_results = ($upload_results | append {
asset: ($asset | path basename)
status: "success"
size: (ls $asset | get 0.size)
})
} else {
$upload_results = ($upload_results | append {
asset: ($asset | path basename)
status: "failed"
reason: $upload_result.stderr
})
}
} catch {|err|
$upload_results = ($upload_results | append {
asset: ($asset | path basename)
status: "failed"
reason: $err.msg
})
}
}
let successful_uploads = ($upload_results | where status == "success" | length)
let total_assets = ($upload_results | length)
{
status: (if $successful_uploads == $total_assets { "success" } else { "partial" })
total_assets: $total_assets
successful_uploads: $successful_uploads
failed_uploads: ($total_assets - $successful_uploads)
assets: $upload_results
}
} catch {|err|
{
status: "failed"
reason: $err.msg
assets: []
}
}
}
# Update project version in files
def update_project_version [repo_root: string, release_config: record] -> record {
log info "Updating project version files..."
if $release_config.dry_run {
return { status: "skipped", reason: "dry run", updated_files: [] }
}
cd $repo_root
let mut updated_files = []
# Update Cargo.toml files
let cargo_files = (find . -name "Cargo.toml" -type f)
for cargo_file in $cargo_files {
try {
let content = (open $cargo_file --raw)
let updated_content = ($content | str replace 'version = "[^"]*"' $'version = "($release_config.version)"')
if $content != $updated_content {
$updated_content | save $cargo_file
$updated_files = ($updated_files | append $cargo_file)
log info $"Updated version in ($cargo_file)"
}
} catch {
log warning $"Failed to update version in ($cargo_file)"
}
}
# Update package.json files
let package_files = (find . -name "package.json" -type f)
for package_file in $package_files {
try {
let package_data = (open $package_file)
let updated_data = ($package_data | upsert version $release_config.version)
$updated_data | to json | save $package_file
$updated_files = ($updated_files | append $package_file)
log info $"Updated version in ($package_file)"
} catch {
log warning $"Failed to update version in ($package_file)"
}
}
{
status: "success"
updated_files: $updated_files
files_updated: ($updated_files | length)
}
}
# Cleanup failed release
def cleanup_failed_release [repo_root: string, release_config: record] {
log info "Cleaning up failed release..."
cd $repo_root
# Delete local tag if it was created
let tag_name = $"v($release_config.version)"
try {
git tag -d $tag_name
log info $"Deleted local tag: ($tag_name)"
} catch {
# Tag might not have been created
}
# Delete remote tag if it was pushed
if $release_config.push_tag {
try {
git push --delete origin $tag_name
log info $"Deleted remote tag: ($tag_name)"
} catch {
# Tag might not have been pushed
}
}
}
# Show release information
def "main info" [] {
let repo_root = ($env.PWD | path dirname | path dirname | path dirname)
cd $repo_root
let latest_tag = try {
git describe --tags --abbrev=0 2>/dev/null | str trim
} catch {
"none"
}
let current_branch = try {
git branch --show-current | str trim
} catch {
"unknown"
}
let commits_since_tag = if $latest_tag != "none" {
try {
git rev-list $"($latest_tag)..HEAD" --count | into int
} catch {
0
}
} else {
try {
git rev-list HEAD --count | into int
} catch {
0
}
}
{
repository: $repo_root
current_branch: $current_branch
latest_tag: $latest_tag
commits_since_tag: $commits_since_tag
next_versions: {
patch: (determine_next_version $repo_root "patch")
minor: (determine_next_version $repo_root "minor")
major: (determine_next_version $repo_root "major")
}
github_cli_available: (try { gh --version | complete } catch { { exit_code: 1 } }).exit_code == 0
}
}
# Preview changelog for upcoming release
def "main preview" [
--version: string = "" # Version for preview
--release-type: string = "patch" # Release type for version calculation
] {
let repo_root = ($env.PWD | path dirname | path dirname | path dirname)
let target_version = if $version == "" {
determine_next_version $repo_root $release_type
} else {
$version
}
let preview_config = { version: $target_version, generate_changelog: true }
let changelog_result = generate_changelog $repo_root $preview_config
print $"Preview of changelog for v($target_version):"
print "=" * 50
print $changelog_result.content
}