#!/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 }