Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions internal/cmd/track_deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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:
Expand Down
169 changes: 169 additions & 0 deletions internal/github/annotations.go
Original file line number Diff line number Diff line change
@@ -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>::<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
}
174 changes: 174 additions & 0 deletions internal/github/annotations_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading