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

615 lines
19 KiB
Plaintext

#!/usr/bin/env nu
# Release rollback tool - rollback problematic releases
#
# Rollbacks:
# - Git tags and releases
# - Package manager entries
# - Container registry tags
# - Distribution artifacts
# - Notification corrections
# - Version reversions
use std log
def main [
--release-version: string # Release version to rollback (required)
--rollback-scope: string = "complete" # Rollback scope: git, github, packages, containers, notifications, complete
--reason: string = "" # Reason for rollback (for documentation)
--replacement-version: string = "" # Replacement version to recommend (optional)
--notify-users: bool = true # Send rollback notifications to users
--cleanup-artifacts: bool = true # Clean up distribution artifacts
--dry-run: bool = false # Show what would be rolled back without doing it
--force: bool = false # Force rollback even with warnings
--verbose: bool = false # Enable verbose logging
] -> record {
if $release_version == "" {
log error "Release version is required for rollback"
exit 1
}
let repo_root = ($env.PWD | path dirname | path dirname | path dirname)
let rollback_scopes = if $rollback_scope == "complete" {
["git", "github", "packages", "containers", "notifications"]
} else {
($rollback_scope | split row "," | each { str trim })
}
let rollback_config = {
release_version: $release_version
scopes: $rollback_scopes
reason: (if $reason == "" { "Release rollback requested" } else { $reason })
replacement_version: $replacement_version
notify_users: $notify_users
cleanup_artifacts: $cleanup_artifacts
dry_run: $dry_run
force: $force
verbose: $verbose
repo_root: $repo_root
}
log info $"Starting release rollback with config: ($rollback_config)"
# Validate rollback request
let validation_result = validate_rollback_request $rollback_config
if $validation_result.status != "valid" and not $rollback_config.force {
log error $"Rollback validation failed: ($validation_result.reason)"
if ($validation_result.warnings | length) > 0 {
log warning "Warnings:"
for warning in $validation_result.warnings {
log warning $" - ($warning)"
}
}
exit 1
}
# Confirm rollback if not forced
if not $rollback_config.force and not $rollback_config.dry_run {
let confirmation = (input $"Are you sure you want to rollback release v($rollback_config.release_version)? [y/N] ")
if not ($confirmation | str downcase | str starts-with "y") {
log info "Rollback cancelled by user"
exit 0
}
}
# Execute rollback for each scope
let rollback_results = $rollback_config.scopes | each {|scope|
execute_rollback_scope $scope $rollback_config
}
# Send rollback notifications if requested
let notification_result = if $rollback_config.notify_users and not $rollback_config.dry_run {
send_rollback_notifications $rollback_config $rollback_results
} else {
{ status: "skipped", reason: "notifications disabled or dry run" }
}
# Generate rollback report
let rollback_report = generate_rollback_report $rollback_config $rollback_results $notification_result
let summary = {
rollback_version: $rollback_config.release_version
total_scopes: ($rollback_config.scopes | length)
successful_rollbacks: ($rollback_results | where status == "success" | length)
failed_rollbacks: ($rollback_results | where status == "failed" | length)
skipped_rollbacks: ($rollback_results | where status == "skipped" | length)
notification_result: $notification_result
rollback_config: $rollback_config
results: $rollback_results
report: $rollback_report
}
if $summary.failed_rollbacks > 0 {
log error $"Release rollback completed with ($summary.failed_rollbacks) failures"
exit 1
} else {
if $rollback_config.dry_run {
log info $"Dry run completed - would rollback ($summary.total_scopes) scopes"
} else {
log info $"Release rollback completed successfully - ($summary.successful_rollbacks) scopes rolled back"
}
}
return $summary
}
# Validate rollback request
def validate_rollback_request [rollback_config: record] -> record {
let mut warnings = []
let mut errors = []
cd $rollback_config.repo_root
# Check if release version exists
let tag_name = $"v($rollback_config.release_version)"
let tag_exists = try {
git tag -l $tag_name | str trim
} catch {
""
}
if $tag_exists == "" {
$errors = ($errors | append $"Release tag ($tag_name) does not exist")
}
# Check if this is the latest release
let latest_tag = try {
git describe --tags --abbrev=0 2>/dev/null | str trim
} catch {
""
}
if $latest_tag != $tag_name {
$warnings = ($warnings | append $"Rolling back ($tag_name) which is not the latest release (latest: ($latest_tag))")
}
# Check repository state
let status_result = (git status --porcelain | complete)
if $status_result.exit_code == 0 and ($status_result.stdout | str trim) != "" {
$warnings = ($warnings | append "Repository has uncommitted changes")
}
# Check GitHub CLI availability for GitHub scope
if "github" in $rollback_config.scopes {
let gh_check = try { gh --version | complete } catch { { exit_code: 1 } }
if $gh_check.exit_code != 0 {
$warnings = ($warnings | append "GitHub CLI not available - GitHub rollback will be skipped")
}
}
let status = if ($errors | length) > 0 {
"invalid"
} else if ($warnings | length) > 0 {
"warning"
} else {
"valid"
}
{
status: $status
errors: $errors
warnings: $warnings
reason: (if ($errors | length) > 0 { ($errors | str join "; ") } else { "" })
}
}
# Execute rollback for specific scope
def execute_rollback_scope [
scope: string
rollback_config: record
] -> record {
log info $"Rolling back scope: ($scope)"
let start_time = (date now)
match $scope {
"git" => { rollback_git_release $rollback_config }
"github" => { rollback_github_release $rollback_config }
"packages" => { rollback_package_releases $rollback_config }
"containers" => { rollback_container_releases $rollback_config }
"notifications" => { rollback_notifications $rollback_config }
_ => {
log warning $"Unknown rollback scope: ($scope)"
{
scope: $scope
status: "failed"
reason: "unknown scope"
duration: ((date now) - $start_time)
}
}
}
}
# Rollback Git release (tag and commits)
def rollback_git_release [rollback_config: record] -> record {
log info "Rolling back Git release..."
let start_time = (date now)
cd $rollback_config.repo_root
if $rollback_config.dry_run {
return {
scope: "git"
status: "success"
tag_name: $"v($rollback_config.release_version)"
dry_run: true
duration: ((date now) - $start_time)
}
}
let tag_name = $"v($rollback_config.release_version)"
let mut actions_taken = []
try {
# Delete local tag
let delete_local = (git tag -d $tag_name | complete)
if $delete_local.exit_code == 0 {
$actions_taken = ($actions_taken | append "deleted local tag")
}
# Delete remote tag
let delete_remote = (git push --delete origin $tag_name | complete)
if $delete_remote.exit_code == 0 {
$actions_taken = ($actions_taken | append "deleted remote tag")
} else {
log warning $"Failed to delete remote tag: ($delete_remote.stderr)"
}
# Note: We don't automatically reset commits as that could be destructive
# Users should manually handle commit rollbacks if needed
{
scope: "git"
status: "success"
tag_name: $tag_name
actions_taken: $actions_taken
duration: ((date now) - $start_time)
}
} catch {|err|
{
scope: "git"
status: "failed"
reason: $err.msg
tag_name: $tag_name
duration: ((date now) - $start_time)
}
}
}
# Rollback GitHub release
def rollback_github_release [rollback_config: record] -> record {
log info "Rolling back GitHub release..."
let start_time = (date now)
# Check GitHub CLI availability
let gh_check = try { gh --version | complete } catch { { exit_code: 1 } }
if $gh_check.exit_code != 0 {
return {
scope: "github"
status: "skipped"
reason: "GitHub CLI not available"
duration: ((date now) - $start_time)
}
}
if $rollback_config.dry_run {
return {
scope: "github"
status: "success"
release_tag: $"v($rollback_config.release_version)"
dry_run: true
duration: ((date now) - $start_time)
}
}
let tag_name = $"v($rollback_config.release_version)"
try {
cd $rollback_config.repo_root
# Check if GitHub release exists
let release_check = (gh release view $tag_name | complete)
if $release_check.exit_code != 0 {
return {
scope: "github"
status: "skipped"
reason: $"GitHub release ($tag_name) does not exist"
duration: ((date now) - $start_time)
}
}
# Delete GitHub release
let delete_result = (gh release delete $tag_name --yes | complete)
if $delete_result.exit_code == 0 {
log info $"Successfully deleted GitHub release: ($tag_name)"
{
scope: "github"
status: "success"
release_tag: $tag_name
duration: ((date now) - $start_time)
}
} else {
{
scope: "github"
status: "failed"
reason: $delete_result.stderr
release_tag: $tag_name
duration: ((date now) - $start_time)
}
}
} catch {|err|
{
scope: "github"
status: "failed"
reason: $err.msg
release_tag: $tag_name
duration: ((date now) - $start_time)
}
}
}
# Rollback package releases (Homebrew, APT, etc.)
def rollback_package_releases [rollback_config: record] -> record {
log info "Rolling back package releases..."
let start_time = (date now)
if $rollback_config.dry_run {
return {
scope: "packages"
status: "success"
packages_affected: ["homebrew", "apt", "yum"]
dry_run: true
duration: ((date now) - $start_time)
}
}
# Package rollback would involve:
# 1. Reverting Homebrew formula to previous version
# 2. Removing packages from APT/YUM repositories
# 3. Updating package indexes
log warning "Package rollback not fully implemented - would revert package manager entries"
{
scope: "packages"
status: "skipped"
reason: "not fully implemented"
packages_affected: []
duration: ((date now) - $start_time)
}
}
# Rollback container releases
def rollback_container_releases [rollback_config: record] -> record {
log info "Rolling back container releases..."
let start_time = (date now)
if $rollback_config.dry_run {
return {
scope: "containers"
status: "success"
images_affected: [$"provisioning:($rollback_config.release_version)"]
dry_run: true
duration: ((date now) - $start_time)
}
}
# Container rollback would involve:
# 1. Removing specific version tags
# 2. Updating 'latest' tag to previous version
# 3. Cleaning up registry entries
log warning "Container rollback not fully implemented - would remove container tags"
{
scope: "containers"
status: "skipped"
reason: "not fully implemented"
images_affected: []
duration: ((date now) - $start_time)
}
}
# Rollback notifications (send corrections)
def rollback_notifications [rollback_config: record] -> record {
log info "Rolling back notifications..."
let start_time = (date now)
if $rollback_config.dry_run {
return {
scope: "notifications"
status: "success"
correction_message: $"Release v($rollback_config.release_version) has been rolled back"
dry_run: true
duration: ((date now) - $start_time)
}
}
# Notification rollback would involve:
# 1. Sending correction messages to all notification channels
# 2. Updating RSS feeds with rollback notice
# 3. Posting rollback announcements
log warning "Notification rollback not fully implemented - would send correction messages"
{
scope: "notifications"
status: "skipped"
reason: "not fully implemented"
duration: ((date now) - $start_time)
}
}
# Send rollback notifications to users
def send_rollback_notifications [
rollback_config: record
rollback_results: list
] -> record {
log info "Sending rollback notifications..."
let rollback_message = generate_rollback_message $rollback_config $rollback_results
# This would use the notification system to send rollback alerts
log warning "Rollback notifications not fully implemented - would send user alerts"
{
status: "skipped"
reason: "not fully implemented"
message: $rollback_message
}
}
# Generate rollback message for notifications
def generate_rollback_message [
rollback_config: record
rollback_results: list
] -> string {
let successful_scopes = ($rollback_results | where status == "success" | get scope)
let replacement_text = if $rollback_config.replacement_version != "" {
$" Please use version ($rollback_config.replacement_version) instead."
} else {
""
}
$"🚨 Release Rollback Notice
Release v($rollback_config.release_version) has been rolled back due to: ($rollback_config.reason)
Affected areas: ($successful_scopes | str join ', ')
($replacement_text)
We apologize for any inconvenience. Please ensure you're using a stable version.
The Provisioning Team"
}
# Generate rollback report
def generate_rollback_report [
rollback_config: record
rollback_results: list
notification_result: record
] -> record {
let report = {
timestamp: (date now)
rollback_version: $rollback_config.release_version
reason: $rollback_config.reason
replacement_version: $rollback_config.replacement_version
summary: {
total_scopes: ($rollback_results | length)
successful: ($rollback_results | where status == "success" | length)
failed: ($rollback_results | where status == "failed" | length)
skipped: ($rollback_results | where status == "skipped" | length)
}
scopes: $rollback_results
notifications: $notification_result
configuration: $rollback_config
}
# Save report to file
let report_file = ($rollback_config.repo_root | path join $"rollback-report-($rollback_config.release_version).json")
$report | to json | save $report_file
log info $"Rollback report saved to: ($report_file)"
return $report
}
# Show rollback status and options
def "main status" [release_version: string = ""] {
let repo_root = ($env.PWD | path dirname | path dirname | path dirname)
cd $repo_root
if $release_version == "" {
# Show general rollback status
let latest_tag = try {
git describe --tags --abbrev=0 2>/dev/null | str trim
} catch {
"none"
}
let recent_tags = try {
git tag -l --sort=-version:refname | head -5 | lines
} catch {
[]
}
return {
repository: $repo_root
latest_release: $latest_tag
recent_releases: $recent_tags
rollback_scopes: ["git", "github", "packages", "containers", "notifications"]
tools_available: {
git: true
github_cli: (try { gh --version | complete } catch { { exit_code: 1 } }).exit_code == 0
}
}
} else {
# Show status for specific release
let tag_name = $"v($release_version)"
let tag_exists = try {
git tag -l $tag_name | str trim
} catch {
""
}
let github_release_exists = if (try { gh --version | complete } catch { { exit_code: 1 } }).exit_code == 0 {
(try { gh release view $tag_name | complete } catch { { exit_code: 1 } }).exit_code == 0
} else {
false
}
return {
release_version: $release_version
tag_name: $tag_name
git_tag_exists: ($tag_exists != "")
github_release_exists: $github_release_exists
rollback_possible: ($tag_exists != "" or $github_release_exists)
}
}
}
# List recent releases that can be rolled back
def "main list" [] {
let repo_root = ($env.PWD | path dirname | path dirname | path dirname)
cd $repo_root
let tags = try {
git tag -l --sort=-version:refname | head -10 | lines
} catch {
[]
}
let gh_available = (try { gh --version | complete } catch { { exit_code: 1 } }).exit_code == 0
$tags | each {|tag|
let release_exists = if $gh_available {
(try { gh release view $tag | complete } catch { { exit_code: 1 } }).exit_code == 0
} else {
false
}
let tag_date = try {
git log -1 --format=%cd --date=short $tag | str trim
} catch {
"unknown"
}
{
tag: $tag
version: ($tag | str replace "^v" "")
date: $tag_date
github_release: $release_exists
can_rollback: true
}
}
}
# Emergency rollback (interactive)
def "main emergency" [release_version: string] {
log warning $"🚨 EMERGENCY ROLLBACK for v($release_version)"
print "This will immediately rollback the release with minimal checks."
print "Use this only in critical situations (security issues, broken releases, etc.)"
print ""
let confirmation = (input $"Type 'EMERGENCY ROLLBACK' to confirm: ")
if $confirmation != "EMERGENCY ROLLBACK" {
log info "Emergency rollback cancelled"
exit 0
}
# Execute emergency rollback with force
main $release_version --rollback-scope complete --force --notify-users --reason "Emergency rollback" --verbose
}