From 3c62f1eb81b097caabb6edaf2609151788b3101a Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 15 Dec 2025 18:21:49 +0100 Subject: [PATCH 1/3] feat: add sandbox mode, pre-flight checks, and rollback to release.sh - Add --sandbox flag to test releases in isolated temp directory - Add --force flag to skip confirmations (for CI automation) - Add --json flag for machine-readable output - Add --verbose flag for detailed logging - Add --rollback flag to restore from backup - Add pre-flight checks (gh auth, git clean, required files, etc.) - Add automatic backup/rollback on failure - Add proper exit codes (0=success, 1=validation, 2=execution) - Redirect all logs to stderr, only JSON to stdout - Add tests for new functionality --- .gitignore | 1 + release.sh | 624 +++++++++++++++++++++-- tests/unit/fixtures/release/CHANGELOG.md | 3 + tests/unit/release_test.sh | 169 ++++++ 4 files changed, 764 insertions(+), 33 deletions(-) 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..c9700e6b 100755 --- a/release.sh +++ b/release.sh @@ -4,6 +4,11 @@ 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 +declare -r EXIT_EXECUTION_ERROR=2 + # Colors RED='\033[0;31m' GREEN='\033[0;32m' @@ -11,12 +16,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 +43,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 +82,433 @@ 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 +} + +######################### +### 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=("bashunit" "install.sh" "package.json" "CHANGELOG.md" "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 + + release::log_info "Running pre-flight checks..." + + if ! release::preflight::check_gh_installed; then + checks_passed=false + fi + + if ! release::preflight::check_gh_auth; then + checks_passed=false + fi + + if ! release::preflight::check_git_clean; then + checks_passed=false + fi + + if ! release::preflight::check_branch_main; then + checks_passed=false + fi + + if ! release::preflight::check_network; then + checks_passed=false + fi + + if ! release::preflight::check_required_files; then + checks_passed=false + fi + + if ! release::preflight::check_changelog_unreleased; then + checks_passed=false + fi + + 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..." + release::backup::save_file "bashunit" + release::backup::save_file "install.sh" + release::backup::save_file "package.json" + release::backup::save_file "CHANGELOG.md" + 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=$(ls -td .release-state/backup-* 2>/dev/null | 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=$(which 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() { + echo "" >&2 + release::log_info "=== SANDBOX RESULTS ===" + echo "" >&2 + + release::log_info "Files changed:" + git diff HEAD~1 --stat 2>/dev/null || true + echo "" >&2 + + release::log_info "Commits made:" + git log --oneline HEAD~1..HEAD 2>/dev/null || git log --oneline -1 2>/dev/null + echo "" >&2 + + 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 + echo "" >&2 + 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" + echo "" >&2 + + # 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..." + echo "" >&2 + + # 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" + + echo "" >&2 + release::build_project + + echo "" >&2 + release::update_checksum + + echo "" >&2 + # Commit changes (confirmations skipped in sandbox) + release::log_info "Creating release commit..." + git add bashunit install.sh package.json CHANGELOG.md + 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 + + echo "" >&2 + echo "========================================" >&2 + echo -e "${GREEN}Sandbox release simulation complete!${NC}" >&2 + echo "========================================" >&2 + echo "" >&2 + + # Go back to original directory before cleanup prompt + cd "$SCRIPT_DIR" + release::sandbox::cleanup } function release::validate_semver() { @@ -289,7 +746,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 +762,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 "========================================" >&2 + echo -e "${GREEN}Release $new_version complete!${NC}" >&2 + echo "========================================" >&2 + echo "" >&2 } ######################### @@ -383,13 +873,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 +907,7 @@ function release::main() { else release::log_error "Unknown argument: $1" release::show_usage - exit 1 + exit $EXIT_VALIDATION_ERROR fi shift ;; @@ -408,7 +918,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 +930,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 "" + echo "" >&2 release::log_warning "DRY-RUN MODE - No files will be modified" - echo "" + echo "" >&2 + 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 "" + echo "" >&2 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 "" + echo "" >&2 release::build_project + release::state::record_step "build_project" - echo "" + echo "" >&2 release::update_checksum + release::state::record_step "update_checksum" - echo "" + echo "" >&2 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 "" + echo "" >&2 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 "" + echo "" >&2 release::create_github_release "$VERSION" "$RELEASE_NOTES_FILE" + release::state::record_step "create_github_release" - echo "" + echo "" >&2 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..2d13813b 100644 --- a/tests/unit/release_test.sh +++ b/tests/unit/release_test.sh @@ -277,3 +277,172 @@ 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" + 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" + 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" + 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() { + VERSION="0.31.0" + CURRENT_VERSION="0.30.0" + SANDBOX_MODE=false + DRY_RUN=false + 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=() + 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" +} From cd68804455bbf3e548d9133bf2431501f02a7633 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 15 Dec 2025 18:56:57 +0100 Subject: [PATCH 2/3] fix: linter --- release.sh | 3 ++- tests/unit/release_test.sh | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/release.sh b/release.sh index c9700e6b..1f01a438 100755 --- a/release.sh +++ b/release.sh @@ -7,6 +7,7 @@ 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 # Colors @@ -320,7 +321,7 @@ function release::rollback::manual() { fi local latest_backup - latest_backup=$(ls -td .release-state/backup-* 2>/dev/null | head -1) + 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" diff --git a/tests/unit/release_test.sh b/tests/unit/release_test.sh index 2d13813b..75f2ff78 100644 --- a/tests/unit/release_test.sh +++ b/tests/unit/release_test.sh @@ -327,7 +327,7 @@ function test_backup_init_creates_directory() { temp_dir=$(mktemp -d) ( - cd "$temp_dir" + cd "$temp_dir" || return release::backup::init [[ -d "$BACKUP_DIR" ]] && echo "exists" ) > /tmp/backup_test_result 2>&1 @@ -343,7 +343,7 @@ function test_backup_save_file_copies_file() { local result result=$( - cd "$temp_dir" + cd "$temp_dir" || return echo "test content" > testfile.txt release::backup::init release::backup::save_file "testfile.txt" @@ -360,7 +360,7 @@ function test_rollback_restore_files_restores_backup() { local result result=$( - cd "$temp_dir" + cd "$temp_dir" || return echo "original content" > testfile.txt release::backup::init release::backup::save_file "testfile.txt" @@ -408,10 +408,15 @@ function test_json_summary_generates_valid_json() { } 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=() @@ -427,6 +432,7 @@ function test_json_summary_handles_empty_steps() { 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" From eafad66a456c7547c1c81f8d16fa393be522de25 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 15 Dec 2025 19:05:07 +0100 Subject: [PATCH 3/3] ref: improve release.sh code quality - Replace 'which' with 'command -v' for POSIX compliance - Simplify preflight::check_all using array iteration - Simplify backup::save_all using array iteration --- release.sh | 182 ++++++++++++++++++++++++++--------------------------- 1 file changed, 89 insertions(+), 93 deletions(-) diff --git a/release.sh b/release.sh index 1f01a438..6120260b 100755 --- a/release.sh +++ b/release.sh @@ -10,6 +10,11 @@ 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' @@ -103,6 +108,26 @@ function release::error_with_suggestion() { 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 ### ######################### @@ -171,7 +196,7 @@ function release::preflight::check_network() { function release::preflight::check_required_files() { release::log_verbose "Checking required files..." - local required_files=("bashunit" "install.sh" "package.json" "CHANGELOG.md" "build.sh") + local required_files=("${RELEASE_FILES[@]}" "build.sh") local missing=() for file in "${required_files[@]}"; do @@ -214,36 +239,23 @@ function release::preflight::check_changelog_unreleased() { 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..." - if ! release::preflight::check_gh_installed; then - checks_passed=false - fi - - if ! release::preflight::check_gh_auth; then - checks_passed=false - fi - - if ! release::preflight::check_git_clean; then - checks_passed=false - fi - - if ! release::preflight::check_branch_main; then - checks_passed=false - fi - - if ! release::preflight::check_network; then - checks_passed=false - fi - - if ! release::preflight::check_required_files; then - checks_passed=false - fi - - if ! release::preflight::check_changelog_unreleased; then - checks_passed=false - fi + 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" @@ -275,10 +287,9 @@ function release::backup::save_file() { function release::backup::save_all() { release::log_verbose "Backing up files before modification..." - release::backup::save_file "bashunit" - release::backup::save_file "install.sh" - release::backup::save_file "package.json" - release::backup::save_file "CHANGELOG.md" + for file in "${RELEASE_FILES[@]}"; do + release::backup::save_file "$file" + done release::log_verbose "All files backed up to $BACKUP_DIR" } @@ -399,7 +410,7 @@ function release::sandbox::mock_gh() { function release::sandbox::mock_git_push() { # Override git push to prevent actual pushes local original_git - original_git=$(which git) + original_git=$(command -v git) git() { if [[ "$1" == "push" ]]; then @@ -412,17 +423,17 @@ function release::sandbox::mock_git_push() { } function release::sandbox::show_results() { - echo "" >&2 + release::blank_line release::log_info "=== SANDBOX RESULTS ===" - echo "" >&2 + release::blank_line release::log_info "Files changed:" git diff HEAD~1 --stat 2>/dev/null || true - echo "" >&2 + release::blank_line release::log_info "Commits made:" git log --oneline HEAD~1..HEAD 2>/dev/null || git log --oneline -1 2>/dev/null - echo "" >&2 + release::blank_line if [[ -f "/tmp/bashunit-release-notes-${VERSION}.md" ]]; then release::log_info "Release notes preview:" @@ -434,7 +445,7 @@ function release::sandbox::show_results() { function release::sandbox::cleanup() { local response - echo "" >&2 + release::blank_line echo -en "${YELLOW}Keep sandbox for inspection? [y/N]: ${NC}" >&2 read -r response @@ -449,7 +460,7 @@ function release::sandbox::cleanup() { function release::sandbox::run() { release::log_warning "SANDBOX MODE - Running in isolated environment" - echo "" >&2 + release::blank_line # Limited pre-flight checks for sandbox (only file checks, not git/gh) if ! release::preflight::check_required_files; then @@ -463,7 +474,7 @@ function release::sandbox::run() { release::sandbox::mock_git_push release::log_info "Starting sandbox release simulation..." - echo "" >&2 + release::blank_line # Run release steps in sandbox (cd already done in setup_git) release::update_bashunit_version "$VERSION" @@ -471,16 +482,16 @@ function release::sandbox::run() { release::update_package_json_version "$VERSION" release::update_changelog "$VERSION" "$CURRENT_VERSION" - echo "" >&2 + release::blank_line release::build_project - echo "" >&2 + release::blank_line release::update_checksum - echo "" >&2 + release::blank_line # Commit changes (confirmations skipped in sandbox) release::log_info "Creating release commit..." - git add bashunit install.sh package.json CHANGELOG.md + git add "${RELEASE_FILES[@]}" git commit -m "release: $VERSION" -n release::log_success "Created commit" git tag "$VERSION" @@ -501,11 +512,11 @@ function release::sandbox::run() { # Show results release::sandbox::show_results - echo "" >&2 + release::blank_line echo "========================================" >&2 echo -e "${GREEN}Sandbox release simulation complete!${NC}" >&2 echo "========================================" >&2 - echo "" >&2 + release::blank_line # Go back to original directory before cleanup prompt cd "$SCRIPT_DIR" @@ -517,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 } @@ -553,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() { @@ -599,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:" @@ -641,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 } @@ -674,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)" } @@ -720,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 @@ -807,7 +803,7 @@ function release::git_commit_and_tag() { return fi - git add bashunit install.sh package.json CHANGELOG.md + git add "${RELEASE_FILES[@]}" git commit -m "release: $new_version" -n release::log_success "Created commit" @@ -855,11 +851,11 @@ function release::update_latest_branch() { function release::print_release_complete() { local new_version=$1 - echo "" >&2 + release::blank_line echo "========================================" >&2 echo -e "${GREEN}Release $new_version complete!${NC}" >&2 echo "========================================" >&2 - echo "" >&2 + release::blank_line } ######################### @@ -947,9 +943,9 @@ function release::main() { fi if [[ "$DRY_RUN" == true ]]; then - echo "" >&2 + release::blank_line release::log_warning "DRY-RUN MODE - No files will be modified" - echo "" >&2 + release::blank_line else # Run pre-flight checks for real releases if ! release::preflight::check_all; then @@ -967,7 +963,7 @@ function release::main() { # Execute release steps release::log_info "Starting release process..." - echo "" >&2 + release::blank_line release::update_bashunit_version "$VERSION" release::state::record_step "update_bashunit_version" @@ -981,15 +977,15 @@ function release::main() { release::update_changelog "$VERSION" "$CURRENT_VERSION" release::state::record_step "update_changelog" - echo "" >&2 + release::blank_line release::build_project release::state::record_step "build_project" - echo "" >&2 + release::blank_line release::update_checksum release::state::record_step "update_checksum" - echo "" >&2 + release::blank_line release::git_commit_and_tag "$VERSION" release::state::record_step "git_commit_and_tag" @@ -997,7 +993,7 @@ function release::main() { RELEASE_NOTES_FILE="/tmp/bashunit-release-notes-${VERSION}.md" CHECKSUM=$(release::get_checksum) - echo "" >&2 + 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:" @@ -1010,11 +1006,11 @@ function release::main() { fi release::state::record_step "generate_release_notes" - echo "" >&2 + release::blank_line release::create_github_release "$VERSION" "$RELEASE_NOTES_FILE" release::state::record_step "create_github_release" - echo "" >&2 + release::blank_line release::update_latest_branch "$VERSION" release::state::record_step "update_latest_branch"