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=
::
+ 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
+}