diff --git a/.gitignore b/.gitignore index e3439ee7..405bacfb 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ report.html local/ tmp/ dev.log +.release-state/ # ai .tasks/ diff --git a/release.sh b/release.sh index c3ef0920..6120260b 100755 --- a/release.sh +++ b/release.sh @@ -4,6 +4,17 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" +# Exit codes +declare -r EXIT_SUCCESS=0 +declare -r EXIT_VALIDATION_ERROR=1 +# shellcheck disable=SC2034 # Reserved for future use +declare -r EXIT_EXECUTION_ERROR=2 + +# Constants +GITHUB_REPO_PATH="TypedDevs/bashunit" +GITHUB_REPO_URL="https://github.com/${GITHUB_REPO_PATH}" +RELEASE_FILES=("bashunit" "install.sh" "package.json" "CHANGELOG.md") + # Colors RED='\033[0;31m' GREEN='\033[0;32m' @@ -11,12 +22,26 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color +# Mode flags DRY_RUN=false +SANDBOX_MODE=false +FORCE_MODE=false +VERBOSE_MODE=false +JSON_OUTPUT=false WITH_GH_RELEASE=false + +# State tracking +RELEASE_STATE_DIR="" +BACKUP_DIR="" +SANDBOX_DIR="" +COMPLETED_STEPS=() + +# Version tracking VERSION="" +CURRENT_VERSION="" function release::show_usage() { - cat <&2 < [options] Arguments: @@ -24,26 +49,38 @@ Arguments: Options: --dry-run Preview changes without modifying any files - --with-gh-release Create the GitHub release (default: print manual instructions) + --sandbox Run in sandbox mode (isolated temp directory) + --force Skip all interactive confirmations (for CI) + --verbose Enable detailed logging + --json Output machine-readable JSON summary + --with-gh-release Create the GitHub release automatically + --rollback Restore files from most recent backup -h, --help Show this help message -Example: - ./release.sh 0.30.0 - ./release.sh 0.30.0 --dry-run - ./release.sh 0.30.0 --with-gh-release +Exit Codes: + 0 Success + 1 Validation error (invalid version, pre-flight check failed) + 2 Execution error (build failed, git failed) + +Examples: + ./release.sh 0.31.0 # Interactive release + ./release.sh 0.31.0 --dry-run # Preview changes + ./release.sh 0.31.0 --sandbox # Test in isolated sandbox + ./release.sh 0.31.0 --force --json # CI mode with JSON output + ./release.sh --rollback # Restore from backup EOF } function release::log_info() { - echo -e "${BLUE}[INFO]${NC} $1" + echo -e "${BLUE}[INFO]${NC} $1" >&2 } function release::log_success() { - echo -e "${GREEN}[OK]${NC} $1" + echo -e "${GREEN}[OK]${NC} $1" >&2 } function release::log_warning() { - echo -e "${YELLOW}[WARN]${NC} $1" + echo -e "${YELLOW}[WARN]${NC} $1" >&2 } function release::log_error() { @@ -51,7 +88,439 @@ function release::log_error() { } function release::log_dry_run() { - echo -e "${YELLOW}[DRY-RUN]${NC} $1" + echo -e "${YELLOW}[DRY-RUN]${NC} $1" >&2 +} + +function release::log_verbose() { + if [[ "$VERBOSE_MODE" == true ]]; then + echo -e "${BLUE}[VERBOSE]${NC} $1" >&2 + fi +} + +function release::log_sandbox() { + echo -e "${YELLOW}[SANDBOX]${NC} $1" >&2 +} + +function release::error_with_suggestion() { + local error=$1 + local suggestion=$2 + release::log_error "$error" + echo -e " ${YELLOW}Suggestion:${NC} $suggestion" >&2 +} + +function release::blank_line() { + echo "" >&2 +} + +function release::update_file_pattern() { + local file=$1 + local pattern=$2 + local replacement=$3 + local description=$4 + + if [[ "$DRY_RUN" == true ]]; then + release::log_dry_run "Would update $description in $file" + return + fi + + sed -i.bak "s|$pattern|$replacement|" "$file" + rm -f "$file.bak" + release::log_success "Updated $description in $file" +} + +######################### +### PRE-FLIGHT CHECKS ### +######################### + +function release::preflight::check_gh_installed() { + release::log_verbose "Checking if gh CLI is installed..." + if ! command -v gh >/dev/null 2>&1; then + release::error_with_suggestion \ + "gh CLI is not installed" \ + "Install from https://cli.github.com/" + return 1 + fi + release::log_verbose "gh CLI is installed" + return 0 +} + +function release::preflight::check_gh_auth() { + release::log_verbose "Checking gh authentication..." + if ! gh auth status >/dev/null 2>&1; then + release::error_with_suggestion \ + "Not authenticated with GitHub" \ + "Run 'gh auth login' to authenticate" + return 1 + fi + release::log_verbose "gh is authenticated" + return 0 +} + +function release::preflight::check_git_clean() { + release::log_verbose "Checking git working directory..." + if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then + release::error_with_suggestion \ + "Working directory has uncommitted changes" \ + "Commit or stash changes first: git stash" + return 1 + fi + release::log_verbose "Working directory is clean" + return 0 +} + +function release::preflight::check_branch_main() { + release::log_verbose "Checking current branch..." + local current_branch + current_branch=$(git branch --show-current 2>/dev/null) + if [[ "$current_branch" != "main" ]]; then + release::error_with_suggestion \ + "Not on main branch (currently on: $current_branch)" \ + "Switch to main: git checkout main" + return 1 + fi + release::log_verbose "On main branch" + return 0 +} + +function release::preflight::check_network() { + release::log_verbose "Checking network connectivity..." + if ! curl --silent --head --fail --max-time 5 https://github.com >/dev/null 2>&1; then + release::error_with_suggestion \ + "Cannot reach github.com" \ + "Check your network connection" + return 1 + fi + release::log_verbose "Network connectivity OK" + return 0 +} + +function release::preflight::check_required_files() { + release::log_verbose "Checking required files..." + local required_files=("${RELEASE_FILES[@]}" "build.sh") + local missing=() + + for file in "${required_files[@]}"; do + if [[ ! -f "$file" ]]; then + missing+=("$file") + fi + done + + if [[ ${#missing[@]} -gt 0 ]]; then + release::error_with_suggestion \ + "Required files missing: ${missing[*]}" \ + "Ensure you're in the project root directory" + return 1 + fi + release::log_verbose "All required files present" + return 0 +} + +function release::preflight::check_changelog_unreleased() { + release::log_verbose "Checking CHANGELOG.md for Unreleased section..." + if ! grep -q "^## Unreleased$" CHANGELOG.md 2>/dev/null; then + release::error_with_suggestion \ + "CHANGELOG.md is missing '## Unreleased' section" \ + "Add '## Unreleased' section at the top of CHANGELOG.md" + return 1 + fi + + # Check if there's content between ## Unreleased and next ## [ + local unreleased_content + unreleased_content=$(awk '/^## Unreleased$/,/^## \[/' CHANGELOG.md | grep -v "^## " | grep -v "^$" | head -1) + if [[ -z "$unreleased_content" ]]; then + release::error_with_suggestion \ + "CHANGELOG.md Unreleased section has no content" \ + "Add release notes under '## Unreleased' section" + return 1 + fi + release::log_verbose "CHANGELOG.md has Unreleased section with content" + return 0 +} + +function release::preflight::check_all() { + local checks_passed=true + local preflight_checks=( + "release::preflight::check_gh_installed" + "release::preflight::check_gh_auth" + "release::preflight::check_git_clean" + "release::preflight::check_branch_main" + "release::preflight::check_network" + "release::preflight::check_required_files" + "release::preflight::check_changelog_unreleased" + ) + + release::log_info "Running pre-flight checks..." + + for check in "${preflight_checks[@]}"; do + if ! "$check"; then + checks_passed=false + fi + done + + if [[ "$checks_passed" == true ]]; then + release::log_success "All pre-flight checks passed" + return 0 + else + release::log_error "Pre-flight checks failed" + return 1 + fi +} + +######################### +### BACKUP & ROLLBACK ### +######################### + +function release::backup::init() { + RELEASE_STATE_DIR=".release-state" + BACKUP_DIR="$RELEASE_STATE_DIR/backup-$(date +%Y%m%d-%H%M%S)" + mkdir -p "$BACKUP_DIR" + release::log_verbose "Created backup directory: $BACKUP_DIR" +} + +function release::backup::save_file() { + local file=$1 + if [[ -f "$file" ]]; then + cp "$file" "$BACKUP_DIR/" + release::log_verbose "Backed up: $file" + fi +} + +function release::backup::save_all() { + release::log_verbose "Backing up files before modification..." + for file in "${RELEASE_FILES[@]}"; do + release::backup::save_file "$file" + done + release::log_verbose "All files backed up to $BACKUP_DIR" +} + +function release::state::record_step() { + local step=$1 + COMPLETED_STEPS+=("$step") + release::log_verbose "Completed step: $step" +} + +function release::rollback::restore_files() { + if [[ -z "$BACKUP_DIR" ]] || [[ ! -d "$BACKUP_DIR" ]]; then + release::log_error "No backup directory found" + return 1 + fi + + release::log_info "Restoring files from backup..." + for file in "$BACKUP_DIR"/*; do + if [[ -f "$file" ]]; then + local filename + filename=$(basename "$file") + cp "$file" "./$filename" + release::log_verbose "Restored: $filename" + fi + done + release::log_success "Files restored from backup" +} + +function release::rollback::auto() { + release::log_error "Release failed. Initiating rollback..." + release::rollback::restore_files || true + release::log_info "Rollback complete. Files restored to pre-release state." + release::log_info "Manual rollback command if needed: ./release.sh --rollback" +} + +function release::rollback::manual() { + # Find most recent backup + if [[ ! -d ".release-state" ]]; then + release::log_error "No .release-state directory found" + exit $EXIT_VALIDATION_ERROR + fi + + local latest_backup + latest_backup=$(find .release-state -maxdepth 1 -type d -name 'backup-*' 2>/dev/null | sort -r | head -1) + + if [[ -z "$latest_backup" ]]; then + release::log_error "No backup found in .release-state" + exit $EXIT_VALIDATION_ERROR + fi + + release::log_info "Found backup: $latest_backup" + BACKUP_DIR="$latest_backup" + release::rollback::restore_files + release::log_success "Manual rollback complete" +} + +function release::cleanup::state_dir() { + if [[ -d "$RELEASE_STATE_DIR" ]]; then + rm -rf "$RELEASE_STATE_DIR" + release::log_verbose "Cleaned up $RELEASE_STATE_DIR" + fi +} + +function release::setup_rollback_trap() { + trap 'release::rollback::auto' ERR +} + +function release::clear_rollback_trap() { + trap - ERR +} + +######################### +### SANDBOX MODE ### +######################### + +function release::sandbox::create() { + SANDBOX_DIR=$(mktemp -d "/tmp/bashunit-release-sandbox-XXXX") + release::log_info "Creating sandbox at: $SANDBOX_DIR" + + # Copy repo content excluding .git + rsync -a --exclude='.git' --exclude='.release-state' --exclude='node_modules' . "$SANDBOX_DIR/" + release::log_verbose "Copied project files to sandbox" +} + +function release::sandbox::setup_git() { + cd "$SANDBOX_DIR" + git init --quiet + git config user.name "Release Sandbox" + git config user.email "sandbox@local" + git add . + git commit --quiet -m "Initial sandbox state" + release::log_verbose "Initialized git repository in sandbox" +} + +function release::sandbox::mock_gh() { + # Create gh mock function that logs instead of executing + gh() { + release::log_sandbox "Would execute: gh $*" + case "$1" in + release) + release::log_sandbox "GitHub release would be created" + return 0 + ;; + api) + # Return empty for contributor lookup + echo "" + return 0 + ;; + auth) + # Auth status check - return success in sandbox + return 0 + ;; + esac + return 0 + } + export -f gh +} + +function release::sandbox::mock_git_push() { + # Override git push to prevent actual pushes + local original_git + original_git=$(command -v git) + + git() { + if [[ "$1" == "push" ]]; then + release::log_sandbox "Would execute: git $*" + return 0 + fi + "$original_git" "$@" + } + export -f git +} + +function release::sandbox::show_results() { + release::blank_line + release::log_info "=== SANDBOX RESULTS ===" + release::blank_line + + release::log_info "Files changed:" + git diff HEAD~1 --stat 2>/dev/null || true + release::blank_line + + release::log_info "Commits made:" + git log --oneline HEAD~1..HEAD 2>/dev/null || git log --oneline -1 2>/dev/null + release::blank_line + + if [[ -f "/tmp/bashunit-release-notes-${VERSION}.md" ]]; then + release::log_info "Release notes preview:" + echo "----------------------------------------" >&2 + cat "/tmp/bashunit-release-notes-${VERSION}.md" >&2 + echo "----------------------------------------" >&2 + fi +} + +function release::sandbox::cleanup() { + local response + release::blank_line + echo -en "${YELLOW}Keep sandbox for inspection? [y/N]: ${NC}" >&2 + read -r response + + if [[ "$response" =~ ^[Yy]$ ]]; then + release::log_info "Sandbox preserved at: $SANDBOX_DIR" + release::log_info "To clean up later: rm -rf $SANDBOX_DIR" + else + rm -rf "$SANDBOX_DIR" + release::log_success "Sandbox cleaned up" + fi +} + +function release::sandbox::run() { + release::log_warning "SANDBOX MODE - Running in isolated environment" + release::blank_line + + # Limited pre-flight checks for sandbox (only file checks, not git/gh) + if ! release::preflight::check_required_files; then + exit $EXIT_VALIDATION_ERROR + fi + + # Create and setup sandbox + release::sandbox::create + release::sandbox::setup_git + release::sandbox::mock_gh + release::sandbox::mock_git_push + + release::log_info "Starting sandbox release simulation..." + release::blank_line + + # Run release steps in sandbox (cd already done in setup_git) + release::update_bashunit_version "$VERSION" + release::update_install_version "$VERSION" + release::update_package_json_version "$VERSION" + release::update_changelog "$VERSION" "$CURRENT_VERSION" + + release::blank_line + release::build_project + + release::blank_line + release::update_checksum + + release::blank_line + # Commit changes (confirmations skipped in sandbox) + release::log_info "Creating release commit..." + git add "${RELEASE_FILES[@]}" + git commit -m "release: $VERSION" -n + release::log_success "Created commit" + git tag "$VERSION" + release::log_success "Created tag $VERSION" + + # Generate release notes + RELEASE_NOTES_FILE="/tmp/bashunit-release-notes-${VERSION}.md" + CHECKSUM=$(release::get_checksum) + release::generate_release_notes "$VERSION" "$CURRENT_VERSION" "$CHECKSUM" > "$RELEASE_NOTES_FILE" + release::log_success "Generated release notes" + + # Show what would happen with push/gh release + release::log_sandbox "Would push: git push origin main" + release::log_sandbox "Would push tag: git push origin $VERSION" + release::log_sandbox "Would create GitHub release with assets: bin/bashunit, bin/checksum" + release::log_sandbox "Would update 'latest' branch" + + # Show results + release::sandbox::show_results + + release::blank_line + echo "========================================" >&2 + echo -e "${GREEN}Sandbox release simulation complete!${NC}" >&2 + echo "========================================" >&2 + release::blank_line + + # Go back to original directory before cleanup prompt + cd "$SCRIPT_DIR" + release::sandbox::cleanup } function release::validate_semver() { @@ -59,7 +528,7 @@ function release::validate_semver() { if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then release::log_error "Invalid version format: $version" release::log_error "Version must be in semver format (e.g., 0.30.0)" - exit 1 + exit $EXIT_VALIDATION_ERROR fi } @@ -95,44 +564,29 @@ function release::version_gt() { function release::update_bashunit_version() { local new_version=$1 - local file="bashunit" - - if [[ "$DRY_RUN" == true ]]; then - release::log_dry_run "Would update BASHUNIT_VERSION in $file to $new_version" - return - fi - - sed -i.bak "s/BASHUNIT_VERSION=\"[^\"]*\"/BASHUNIT_VERSION=\"$new_version\"/" "$file" - rm -f "$file.bak" - release::log_success "Updated BASHUNIT_VERSION in $file" + release::update_file_pattern \ + "bashunit" \ + "BASHUNIT_VERSION=\"[^\"]*\"" \ + "BASHUNIT_VERSION=\"$new_version\"" \ + "BASHUNIT_VERSION" } function release::update_install_version() { local new_version=$1 - local file="install.sh" - - if [[ "$DRY_RUN" == true ]]; then - release::log_dry_run "Would update LATEST_BASHUNIT_VERSION in $file to $new_version" - return - fi - - sed -i.bak "s/LATEST_BASHUNIT_VERSION=\"[^\"]*\"/LATEST_BASHUNIT_VERSION=\"$new_version\"/" "$file" - rm -f "$file.bak" - release::log_success "Updated LATEST_BASHUNIT_VERSION in $file" + release::update_file_pattern \ + "install.sh" \ + "LATEST_BASHUNIT_VERSION=\"[^\"]*\"" \ + "LATEST_BASHUNIT_VERSION=\"$new_version\"" \ + "LATEST_BASHUNIT_VERSION" } function release::update_package_json_version() { local new_version=$1 - local file="package.json" - - if [[ "$DRY_RUN" == true ]]; then - release::log_dry_run "Would update version in $file to $new_version" - return - fi - - sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$new_version\"/" "$file" - rm -f "$file.bak" - release::log_success "Updated version in $file" + release::update_file_pattern \ + "package.json" \ + "\"version\": \"[^\"]*\"" \ + "\"version\": \"$new_version\"" \ + "version" } function release::update_changelog() { @@ -141,7 +595,7 @@ function release::update_changelog() { local file="CHANGELOG.md" local today today=$(date +%Y-%m-%d) - local compare_url="https://github.com/TypedDevs/bashunit/compare/${current_version}...${new_version}" + local compare_url="${GITHUB_REPO_URL}/compare/${current_version}...${new_version}" if [[ "$DRY_RUN" == true ]]; then release::log_dry_run "Would update $file:" @@ -183,7 +637,7 @@ function release::get_contributors() { # Get GitHub handles of commit authors since previous version # Uses HEAD since the new version tag doesn't exist yet - gh api "/repos/TypedDevs/bashunit/compare/${prev_version}...HEAD" \ + gh api "/repos/${GITHUB_REPO_PATH}/compare/${prev_version}...HEAD" \ --jq '.commits[].author.login' 2>/dev/null | sort -u | grep -v '^$' || true } @@ -216,7 +670,7 @@ function release::generate_release_notes() { echo "## Checksum" echo "SHA256: \`$checksum\`" echo "" - local compare_url="https://github.com/TypedDevs/bashunit/compare/$prev_version...$new_version" + local compare_url="${GITHUB_REPO_URL}/compare/$prev_version...$new_version" echo "**Full Changelog:** [$prev_version...$new_version]($compare_url)" } @@ -262,7 +716,7 @@ function release::update_checksum() { if [[ -z "$checksum" ]]; then release::log_error "Could not read checksum from bin/checksum" - exit 1 + exit $EXIT_VALIDATION_ERROR fi if [[ "$DRY_RUN" == true ]]; then @@ -289,7 +743,13 @@ function release::confirm_action() { local prompt=$1 local response - echo -en "${YELLOW}$prompt [y/N]: ${NC}" + # In force mode, auto-confirm all actions + if [[ "$FORCE_MODE" == true ]]; then + release::log_info "[FORCE] Auto-confirming: $prompt" + return 0 + fi + + echo -en "${YELLOW}$prompt [y/N]: ${NC}" >&2 read -r response if [[ "$response" =~ ^[Yy]$ ]]; then @@ -299,6 +759,33 @@ function release::confirm_action() { fi } +######################### +### JSON OUTPUT ### +######################### + +function release::json::summary() { + local status=$1 + local steps_json="" + + # Build steps array + if [[ ${#COMPLETED_STEPS[@]} -gt 0 ]]; then + steps_json=$(printf '"%s",' "${COMPLETED_STEPS[@]}" | sed 's/,$//') + fi + + cat <&2 + echo -e "${GREEN}Release $new_version complete!${NC}" >&2 + echo "========================================" >&2 + release::blank_line } ######################### @@ -383,13 +870,33 @@ function release::main() { DRY_RUN=true shift ;; + --sandbox) + SANDBOX_MODE=true + shift + ;; + --force) + FORCE_MODE=true + shift + ;; + --verbose) + VERBOSE_MODE=true + shift + ;; + --json) + JSON_OUTPUT=true + shift + ;; --with-gh-release) WITH_GH_RELEASE=true shift ;; + --rollback) + release::rollback::manual + exit $? + ;; -h|--help) release::show_usage - exit 0 + exit $EXIT_SUCCESS ;; *) if [[ -z "$VERSION" ]]; then @@ -397,7 +904,7 @@ function release::main() { else release::log_error "Unknown argument: $1" release::show_usage - exit 1 + exit $EXIT_VALIDATION_ERROR fi shift ;; @@ -408,7 +915,7 @@ function release::main() { if [[ -z "$VERSION" ]]; then release::log_error "Version argument is required" release::show_usage - exit 1 + exit $EXIT_VALIDATION_ERROR fi release::validate_semver "$VERSION" @@ -420,57 +927,105 @@ function release::main() { # Validate new version is greater if ! release::version_gt "$VERSION" "$CURRENT_VERSION"; then - release::log_error "New version ($VERSION) must be greater than current version ($CURRENT_VERSION)" - exit 1 + release::error_with_suggestion \ + "New version ($VERSION) must be greater than current version ($CURRENT_VERSION)" \ + "Use a version number higher than $CURRENT_VERSION" + exit $EXIT_VALIDATION_ERROR + fi + + # Route to appropriate mode + if [[ "$SANDBOX_MODE" == true ]]; then + release::sandbox::run + if [[ "$JSON_OUTPUT" == true ]]; then + release::json::summary "success" + fi + exit $EXIT_SUCCESS fi if [[ "$DRY_RUN" == true ]]; then - echo "" + release::blank_line release::log_warning "DRY-RUN MODE - No files will be modified" - echo "" + release::blank_line + else + # Run pre-flight checks for real releases + if ! release::preflight::check_all; then + if [[ "$JSON_OUTPUT" == true ]]; then + release::json::summary "failed" + fi + exit $EXIT_VALIDATION_ERROR + fi + + # Initialize backup/rollback system + release::backup::init + release::backup::save_all + release::setup_rollback_trap fi # Execute release steps release::log_info "Starting release process..." - echo "" + release::blank_line release::update_bashunit_version "$VERSION" + release::state::record_step "update_bashunit_version" + release::update_install_version "$VERSION" + release::state::record_step "update_install_version" + release::update_package_json_version "$VERSION" + release::state::record_step "update_package_json_version" + release::update_changelog "$VERSION" "$CURRENT_VERSION" + release::state::record_step "update_changelog" - echo "" + release::blank_line release::build_project + release::state::record_step "build_project" - echo "" + release::blank_line release::update_checksum + release::state::record_step "update_checksum" - echo "" + release::blank_line release::git_commit_and_tag "$VERSION" + release::state::record_step "git_commit_and_tag" # Generate formatted release notes RELEASE_NOTES_FILE="/tmp/bashunit-release-notes-${VERSION}.md" CHECKSUM=$(release::get_checksum) - echo "" + release::blank_line if [[ "$DRY_RUN" == true ]]; then release::log_dry_run "Would save release notes to $RELEASE_NOTES_FILE" release::log_dry_run "Release notes content:" - echo "----------------------------------------" - release::generate_release_notes "$VERSION" "$CURRENT_VERSION" "$CHECKSUM" - echo "----------------------------------------" + echo "----------------------------------------" >&2 + release::generate_release_notes "$VERSION" "$CURRENT_VERSION" "$CHECKSUM" >&2 + echo "----------------------------------------" >&2 else release::generate_release_notes "$VERSION" "$CURRENT_VERSION" "$CHECKSUM" > "$RELEASE_NOTES_FILE" release::log_success "Saved release notes to $RELEASE_NOTES_FILE" fi + release::state::record_step "generate_release_notes" - echo "" + release::blank_line release::create_github_release "$VERSION" "$RELEASE_NOTES_FILE" + release::state::record_step "create_github_release" - echo "" + release::blank_line release::update_latest_branch "$VERSION" + release::state::record_step "update_latest_branch" + + # Cleanup on success + if [[ "$DRY_RUN" != true ]]; then + release::clear_rollback_trap + release::cleanup::state_dir + fi release::print_release_complete "$VERSION" + + # Output JSON summary if requested (to stdout) + if [[ "$JSON_OUTPUT" == true ]]; then + release::json::summary "success" + fi } # Only run main when script is executed directly (not sourced) diff --git a/tests/unit/fixtures/release/CHANGELOG.md b/tests/unit/fixtures/release/CHANGELOG.md index c3170ddd..1a251e20 100644 --- a/tests/unit/fixtures/release/CHANGELOG.md +++ b/tests/unit/fixtures/release/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Upcoming feature + ## [0.30.0](https://github.com/TypedDevs/bashunit/compare/0.29.0...0.30.0) - 2025-12-14 ### Added diff --git a/tests/unit/release_test.sh b/tests/unit/release_test.sh index abfcc214..75f2ff78 100644 --- a/tests/unit/release_test.sh +++ b/tests/unit/release_test.sh @@ -277,3 +277,178 @@ function test_generate_release_notes_excludes_older_version_content() { # Should NOT include content from older versions (0.29.0) assert_not_contains "Previous feature" "$result" } + +########################## +# Pre-flight check tests +########################## + +function test_preflight_check_required_files_passes_when_all_exist() { + # Run in the project root where all files exist + local result + result=$(cd "$RELEASE_SCRIPT_DIR" && release::preflight::check_required_files 2>&1) + assert_successful_code +} + +function test_preflight_check_required_files_fails_when_file_missing() { + local temp_dir + temp_dir=$(mktemp -d) + + local result + result=$(cd "$temp_dir" && release::preflight::check_required_files 2>&1) || true + + assert_contains "Required files missing" "$result" + rm -rf "$temp_dir" +} + +function test_preflight_check_changelog_unreleased_passes_with_content() { + local result + result=$(cd "$FIXTURES_DIR" && release::preflight::check_changelog_unreleased 2>&1) + assert_successful_code +} + +function test_preflight_check_changelog_unreleased_fails_when_missing() { + local temp_dir + temp_dir=$(mktemp -d) + echo "# Changelog" > "$temp_dir/CHANGELOG.md" + + local result + result=$(cd "$temp_dir" && release::preflight::check_changelog_unreleased 2>&1) || true + + assert_contains "missing '## Unreleased' section" "$result" + rm -rf "$temp_dir" +} + +########################## +# Backup and rollback tests +########################## + +function test_backup_init_creates_directory() { + local temp_dir + temp_dir=$(mktemp -d) + + ( + cd "$temp_dir" || return + release::backup::init + [[ -d "$BACKUP_DIR" ]] && echo "exists" + ) > /tmp/backup_test_result 2>&1 + + assert_contains "exists" "$(cat /tmp/backup_test_result)" || true + rm -rf "$temp_dir" /tmp/backup_test_result + assert_successful_code +} + +function test_backup_save_file_copies_file() { + local temp_dir + temp_dir=$(mktemp -d) + + local result + result=$( + cd "$temp_dir" || return + echo "test content" > testfile.txt + release::backup::init + release::backup::save_file "testfile.txt" + cat "$BACKUP_DIR/testfile.txt" + ) + + assert_same "test content" "$result" + rm -rf "$temp_dir" +} + +function test_rollback_restore_files_restores_backup() { + local temp_dir + temp_dir=$(mktemp -d) + + local result + result=$( + cd "$temp_dir" || return + echo "original content" > testfile.txt + release::backup::init + release::backup::save_file "testfile.txt" + echo "modified content" > testfile.txt + release::rollback::restore_files 2>/dev/null + cat testfile.txt + ) + + assert_same "original content" "$result" + rm -rf "$temp_dir" +} + +########################## +# Force mode tests +########################## + +function test_confirm_action_auto_confirms_in_force_mode() { + FORCE_MODE=true + local result + result=$(release::confirm_action "Test prompt" 2>&1) + local exit_code=$? + FORCE_MODE=false + assert_same 0 "$exit_code" +} + +########################## +# JSON output tests +########################## + +function test_json_summary_generates_valid_json() { + VERSION="0.31.0" + CURRENT_VERSION="0.30.0" + SANDBOX_MODE=false + DRY_RUN=false + FORCE_MODE=false + COMPLETED_STEPS=("step1" "step2") + + local result + result=$(release::json::summary "success") + + assert_contains '"status": "success"' "$result" + assert_contains '"version": "0.31.0"' "$result" + assert_contains '"current_version": "0.30.0"' "$result" + assert_contains '"completed_steps": ["step1","step2"]' "$result" +} + +function test_json_summary_handles_empty_steps() { + # shellcheck disable=SC2034 # Variables used by release::json::summary + VERSION="0.31.0" + # shellcheck disable=SC2034 + CURRENT_VERSION="0.30.0" + # shellcheck disable=SC2034 + SANDBOX_MODE=false + # shellcheck disable=SC2034 + DRY_RUN=false + # shellcheck disable=SC2034 + FORCE_MODE=false + COMPLETED_STEPS=() + + local result + result=$(release::json::summary "success") + + assert_contains '"completed_steps": []' "$result" +} + +########################## +# State tracking tests +########################## + +function test_state_record_step_adds_to_completed_steps() { + COMPLETED_STEPS=() + # shellcheck disable=SC2034 # Used by release::state::record_step + VERBOSE_MODE=false + + release::state::record_step "test_step" + + assert_same "test_step" "${COMPLETED_STEPS[0]}" +} + +########################## +# Error with suggestion tests +########################## + +function test_error_with_suggestion_shows_both_messages() { + local result + result=$(release::error_with_suggestion "Test error" "Test suggestion" 2>&1) + + assert_contains "Test error" "$result" + assert_contains "Suggestion:" "$result" + assert_contains "Test suggestion" "$result" +}