From 711011ef09d655f1dc5c4e0fa696138d1344c6b9 Mon Sep 17 00:00:00 2001 From: Phil Austin Date: Thu, 27 Nov 2025 08:40:43 -0800 Subject: [PATCH] GitHub Actions annotation --- internal/cmd/track_deployment.go | 4 + internal/github/annotations.go | 169 +++++++++++++++++++++++++++ internal/github/annotations_test.go | 174 ++++++++++++++++++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 internal/github/annotations.go create mode 100644 internal/github/annotations_test.go diff --git a/internal/cmd/track_deployment.go b/internal/cmd/track_deployment.go index 0cab014..630f133 100644 --- a/internal/cmd/track_deployment.go +++ b/internal/cmd/track_deployment.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/viper" "github.com/versioner-io/versioner-cli/internal/api" "github.com/versioner-io/versioner-cli/internal/cicd" + "github.com/versioner-io/versioner-cli/internal/github" "github.com/versioner-io/versioner-cli/internal/status" ) @@ -278,6 +279,9 @@ func handlePreflightError(apiErr *api.APIError) { } } + // Write GitHub Actions annotation if running in GitHub Actions + github.WriteErrorAnnotation(apiErr.StatusCode, code, message, ruleName, retryAfter, details) + // Format output based on status code and error code switch apiErr.StatusCode { case 409: diff --git a/internal/github/annotations.go b/internal/github/annotations.go new file mode 100644 index 0000000..dda05a2 --- /dev/null +++ b/internal/github/annotations.go @@ -0,0 +1,169 @@ +package github + +import ( + "encoding/json" + "fmt" + "os" +) + +// WriteErrorAnnotation writes a GitHub Actions error annotation and job summary +// This makes errors visible in the GitHub Actions UI without digging through logs +func WriteErrorAnnotation(statusCode int, errorCode, message, ruleName string, retryAfter string, details map[string]interface{}) { + // Only write annotations if running in GitHub Actions + if os.Getenv("GITHUB_ACTIONS") != "true" { + return + } + + // Write workflow command annotation + writeWorkflowCommand(statusCode, errorCode, message, ruleName) + + // Write job summary + writeJobSummary(statusCode, errorCode, message, ruleName, retryAfter, details) +} + +// writeWorkflowCommand outputs a GitHub Actions workflow command for error annotation +func writeWorkflowCommand(statusCode int, errorCode, message, ruleName string) { + // Format: ::error title=::<message> + title := formatTitle(statusCode, errorCode, ruleName) + + // Escape special characters in message + escapedMessage := escapeWorkflowCommand(message) + + fmt.Fprintf(os.Stdout, "::error title=%s::%s\n", title, escapedMessage) +} + +// writeJobSummary writes a detailed error summary to GITHUB_STEP_SUMMARY +func writeJobSummary(statusCode int, errorCode, message, ruleName string, retryAfter string, details map[string]interface{}) { + summaryPath := os.Getenv("GITHUB_STEP_SUMMARY") + if summaryPath == "" { + return + } + + var summary string + summary += "## ❌ Versioner Deployment Rejected\n\n" + + // Add status-specific emoji and title + switch statusCode { + case 409: + summary += "### ⚠️ Deployment Conflict\n\n" + case 423: + summary += "### 🔒 Deployment Blocked by Schedule\n\n" + case 428: + summary += "### ❌ Deployment Precondition Failed\n\n" + } + + // Add key information + summary += fmt.Sprintf("- **Error Code:** `%s`\n", errorCode) + if ruleName != "" { + summary += fmt.Sprintf("- **Rule:** %s\n", ruleName) + } + summary += fmt.Sprintf("- **Message:** %s\n", message) + + if retryAfter != "" { + summary += fmt.Sprintf("- **Retry After:** `%s`\n", retryAfter) + } + + summary += "\n" + + // Add specific guidance based on status code and error code + summary += "**Action Required:**\n" + switch statusCode { + case 409: + summary += "- Wait for the current deployment to complete\n" + summary += "- Retry this deployment\n" + + case 423: + if retryAfter != "" { + summary += fmt.Sprintf("- Wait until `%s`\n", retryAfter) + summary += "- Retry automatically after the no-deploy window\n" + } + summary += "- Or use `--skip-preflight-checks` for emergencies\n" + + case 428: + switch errorCode { + case "FLOW_VIOLATION": + summary += "- Deploy to required environments first\n" + summary += "- Then retry this deployment\n" + + case "INSUFFICIENT_SOAK_TIME": + summary += "- Wait for the soak time requirement to be met\n" + if retryAfter != "" { + summary += fmt.Sprintf("- Can deploy at: `%s`\n", retryAfter) + } + summary += "- Or use `--skip-preflight-checks` for emergencies\n" + + case "QUALITY_APPROVAL_REQUIRED", "APPROVAL_REQUIRED": + summary += "- Obtain required approval via Versioner UI\n" + summary += "- Then retry this deployment\n" + + default: + summary += "- Resolve the issue described above\n" + summary += "- Then retry this deployment\n" + summary += "- Or use `--skip-preflight-checks` for emergencies\n" + } + } + + // Add details section if available + if len(details) > 0 { + summary += "\n**Details:**\n" + summary += "```json\n" + detailsJSON, err := json.MarshalIndent(details, "", " ") + if err == nil { + summary += string(detailsJSON) + } + summary += "\n```\n" + } + + // Write to file + f, err := os.OpenFile(summaryPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + // Silently fail - don't break the CLI if we can't write the summary + return + } + defer f.Close() + + _, _ = f.WriteString(summary) +} + +// formatTitle creates a concise title for the error annotation +func formatTitle(statusCode int, errorCode, ruleName string) string { + switch statusCode { + case 409: + return "Deployment Conflict" + case 423: + if ruleName != "" { + return fmt.Sprintf("Deployment Blocked: %s", ruleName) + } + return "Deployment Blocked by Schedule" + case 428: + if ruleName != "" { + return fmt.Sprintf("%s: %s", errorCode, ruleName) + } + return errorCode + default: + return "Deployment Rejected" + } +} + +// escapeWorkflowCommand escapes special characters for GitHub Actions workflow commands +// See: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message +func escapeWorkflowCommand(s string) string { + s = replaceAll(s, "%", "%25") + s = replaceAll(s, "\r", "%0D") + s = replaceAll(s, "\n", "%0A") + return s +} + +// replaceAll is a simple string replacement helper +func replaceAll(s, old, new string) string { + result := "" + for i := 0; i < len(s); i++ { + if i+len(old) <= len(s) && s[i:i+len(old)] == old { + result += new + i += len(old) - 1 + } else { + result += string(s[i]) + } + } + return result +} diff --git a/internal/github/annotations_test.go b/internal/github/annotations_test.go new file mode 100644 index 0000000..4e6d044 --- /dev/null +++ b/internal/github/annotations_test.go @@ -0,0 +1,174 @@ +package github + +import ( + "os" + "testing" +) + +func TestFormatTitle(t *testing.T) { + tests := []struct { + name string + statusCode int + errorCode string + ruleName string + want string + }{ + { + name: "409 conflict", + statusCode: 409, + errorCode: "DEPLOYMENT_IN_PROGRESS", + ruleName: "", + want: "Deployment Conflict", + }, + { + name: "423 with rule name", + statusCode: 423, + errorCode: "NO_DEPLOY_WINDOW", + ruleName: "No Deploy Fridays", + want: "Deployment Blocked: No Deploy Fridays", + }, + { + name: "423 without rule name", + statusCode: 423, + errorCode: "NO_DEPLOY_WINDOW", + ruleName: "", + want: "Deployment Blocked by Schedule", + }, + { + name: "428 with rule name", + statusCode: 428, + errorCode: "FLOW_VIOLATION", + ruleName: "Staging First", + want: "FLOW_VIOLATION: Staging First", + }, + { + name: "428 without rule name", + statusCode: 428, + errorCode: "INSUFFICIENT_SOAK_TIME", + ruleName: "", + want: "INSUFFICIENT_SOAK_TIME", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatTitle(tt.statusCode, tt.errorCode, tt.ruleName) + if got != tt.want { + t.Errorf("formatTitle() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestEscapeWorkflowCommand(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "no special chars", + input: "simple message", + want: "simple message", + }, + { + name: "with percent", + input: "100% complete", + want: "100%25 complete", + }, + { + name: "with newline", + input: "line1\nline2", + want: "line1%0Aline2", + }, + { + name: "with carriage return", + input: "line1\rline2", + want: "line1%0Dline2", + }, + { + name: "multiple special chars", + input: "50%\ndone\r", + want: "50%25%0Adone%0D", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := escapeWorkflowCommand(tt.input) + if got != tt.want { + t.Errorf("escapeWorkflowCommand() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestWriteErrorAnnotation_NotInGitHub(t *testing.T) { + // Ensure GITHUB_ACTIONS is not set + os.Unsetenv("GITHUB_ACTIONS") + + // Should not panic or error when not in GitHub Actions + WriteErrorAnnotation(423, "NO_DEPLOY_WINDOW", "Test message", "Test Rule", "", nil) +} + +func TestWriteErrorAnnotation_InGitHub(t *testing.T) { + // Set GitHub Actions environment + os.Setenv("GITHUB_ACTIONS", "true") + defer os.Unsetenv("GITHUB_ACTIONS") + + // Create a temporary file for GITHUB_STEP_SUMMARY + tmpFile, err := os.CreateTemp("", "github-summary-*.md") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.Close() + + os.Setenv("GITHUB_STEP_SUMMARY", tmpFile.Name()) + defer os.Unsetenv("GITHUB_STEP_SUMMARY") + + // Call the function + details := map[string]interface{}{ + "rule_name": "Test Rule", + "window_start": "2025-11-27T00:00:00Z", + } + WriteErrorAnnotation(423, "NO_DEPLOY_WINDOW", "No deployments allowed", "Test Rule", "2025-11-27T23:59:59Z", details) + + // Read the summary file + content, err := os.ReadFile(tmpFile.Name()) + if err != nil { + t.Fatalf("Failed to read summary file: %v", err) + } + + summary := string(content) + + // Verify key content is present + if !contains(summary, "Versioner Deployment Rejected") { + t.Error("Summary should contain 'Versioner Deployment Rejected'") + } + if !contains(summary, "NO_DEPLOY_WINDOW") { + t.Error("Summary should contain error code") + } + if !contains(summary, "Test Rule") { + t.Error("Summary should contain rule name") + } + if !contains(summary, "No deployments allowed") { + t.Error("Summary should contain message") + } + if !contains(summary, "2025-11-27T23:59:59Z") { + t.Error("Summary should contain retry_after timestamp") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && findSubstring(s, substr)) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}