Files
beads/cmd/bd/preflight.go
Jordan Hubbard aa2ea48bf2 feat: add FreeBSD release builds (#832)
* feat: add FreeBSD release builds

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* chore: allow manual release dispatch

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: stabilize release workflow on fork

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: clean zig download artifact

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: use valid zig target for freebsd arm

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: disable freebsd arm release build

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: switch freebsd build to pure go

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: skip release publishing on forks

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: satisfy golangci-lint for release PR

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-01-01 10:51:51 -08:00

337 lines
8.6 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"github.com/spf13/cobra"
)
// CheckResult represents the result of a single preflight check.
type CheckResult struct {
Name string `json:"name"`
Passed bool `json:"passed"`
Skipped bool `json:"skipped,omitempty"`
Warning bool `json:"warning,omitempty"`
Output string `json:"output,omitempty"`
Command string `json:"command"`
}
// PreflightResult represents the overall preflight check results.
type PreflightResult struct {
Checks []CheckResult `json:"checks"`
Passed bool `json:"passed"`
Summary string `json:"summary"`
}
var preflightCmd = &cobra.Command{
Use: "preflight",
GroupID: "maint",
Short: "Show PR readiness checklist",
Long: `Display a checklist of common pre-PR checks for contributors.
This command helps catch common issues before pushing to CI:
- Tests not run locally
- Lint errors
- Stale nix vendorHash
- Version mismatches
Examples:
bd preflight # Show checklist
bd preflight --check # Run checks automatically
bd preflight --check --json # JSON output for programmatic use
`,
Run: runPreflight,
}
func init() {
preflightCmd.Flags().Bool("check", false, "Run checks automatically")
preflightCmd.Flags().Bool("fix", false, "Auto-fix issues where possible (not yet implemented)")
preflightCmd.Flags().Bool("json", false, "Output results as JSON")
rootCmd.AddCommand(preflightCmd)
}
func runPreflight(cmd *cobra.Command, args []string) {
check, _ := cmd.Flags().GetBool("check")
fix, _ := cmd.Flags().GetBool("fix")
jsonOutput, _ := cmd.Flags().GetBool("json")
if fix {
fmt.Println("Note: --fix is not yet implemented.")
fmt.Println("See bd-lfak.3 through bd-lfak.5 for implementation roadmap.")
fmt.Println()
}
if check {
runChecks(jsonOutput)
return
}
// Static checklist mode
fmt.Println("PR Readiness Checklist:")
fmt.Println()
fmt.Println("[ ] Tests pass: go test -short ./...")
fmt.Println("[ ] Lint passes: golangci-lint run ./...")
fmt.Println("[ ] No beads pollution: check .beads/issues.jsonl diff")
fmt.Println("[ ] Nix hash current: go.sum unchanged or vendorHash updated")
fmt.Println("[ ] Version sync: version.go matches default.nix")
fmt.Println()
fmt.Println("Run 'bd preflight --check' to validate automatically.")
}
// runChecks executes all preflight checks and reports results.
func runChecks(jsonOutput bool) {
var results []CheckResult
// Run test check
testResult := runTestCheck()
results = append(results, testResult)
// Run lint check
lintResult := runLintCheck()
results = append(results, lintResult)
// Run nix hash check
nixResult := runNixHashCheck()
results = append(results, nixResult)
// Run version sync check
versionResult := runVersionSyncCheck()
results = append(results, versionResult)
// Calculate overall result
allPassed := true
passCount := 0
skipCount := 0
warnCount := 0
for _, r := range results {
if r.Skipped {
skipCount++
} else if r.Warning {
warnCount++
// Warnings don't fail the overall result but count as "not passed"
} else if r.Passed {
passCount++
} else {
allPassed = false
}
}
runCount := len(results) - skipCount
summary := fmt.Sprintf("%d/%d checks passed", passCount, runCount)
if warnCount > 0 {
summary += fmt.Sprintf(", %d warning(s)", warnCount)
}
if skipCount > 0 {
summary += fmt.Sprintf(" (%d skipped)", skipCount)
}
if jsonOutput {
result := PreflightResult{
Checks: results,
Passed: allPassed,
Summary: summary,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(result); err != nil {
fmt.Fprintf(os.Stderr, "Error encoding preflight result: %v\n", err)
}
} else {
// Human-readable output
for _, r := range results {
if r.Skipped {
fmt.Printf("⚠ %s (skipped)\n", r.Name)
} else if r.Warning {
fmt.Printf("⚠ %s\n", r.Name)
} else if r.Passed {
fmt.Printf("✓ %s\n", r.Name)
} else {
fmt.Printf("✗ %s\n", r.Name)
}
fmt.Printf(" Command: %s\n", r.Command)
if r.Skipped && r.Output != "" {
// Show skip reason
fmt.Printf(" Reason: %s\n", r.Output)
} else if r.Warning && r.Output != "" {
// Show warning message
fmt.Printf(" Warning: %s\n", r.Output)
} else if !r.Passed && r.Output != "" {
// Truncate output for terminal display
output := truncateOutput(r.Output, 500)
fmt.Printf(" Output:\n")
for _, line := range strings.Split(output, "\n") {
fmt.Printf(" %s\n", line)
}
}
fmt.Println()
}
fmt.Println(summary)
}
if !allPassed {
os.Exit(1)
}
}
// runTestCheck runs go test -short ./... and returns the result.
func runTestCheck() CheckResult {
command := "go test -short ./..."
cmd := exec.Command("go", "test", "-short", "./...")
output, err := cmd.CombinedOutput()
return CheckResult{
Name: "Tests pass",
Passed: err == nil,
Output: string(output),
Command: command,
}
}
// runLintCheck runs golangci-lint and returns the result.
func runLintCheck() CheckResult {
command := "golangci-lint run ./..."
// Check if golangci-lint is available
if _, err := exec.LookPath("golangci-lint"); err != nil {
return CheckResult{
Name: "Lint passes",
Passed: false,
Skipped: true,
Output: "golangci-lint not found in PATH",
Command: command,
}
}
cmd := exec.Command("golangci-lint", "run", "./...")
output, err := cmd.CombinedOutput()
return CheckResult{
Name: "Lint passes",
Passed: err == nil,
Output: string(output),
Command: command,
}
}
// runNixHashCheck checks if go.sum has uncommitted changes that may require vendorHash update.
func runNixHashCheck() CheckResult {
command := "git diff HEAD -- go.sum"
// Check for unstaged changes to go.sum
cmd := exec.Command("git", "diff", "--name-only", "HEAD", "--", "go.sum")
output, _ := cmd.Output()
// Check for staged changes to go.sum
stagedCmd := exec.Command("git", "diff", "--name-only", "--cached", "--", "go.sum")
stagedOutput, _ := stagedCmd.Output()
hasChanges := len(strings.TrimSpace(string(output))) > 0 || len(strings.TrimSpace(string(stagedOutput))) > 0
if hasChanges {
return CheckResult{
Name: "Nix hash current",
Passed: false,
Warning: true,
Output: "go.sum has uncommitted changes - vendorHash in default.nix may need updating",
Command: command,
}
}
return CheckResult{
Name: "Nix hash current",
Passed: true,
Output: "",
Command: command,
}
}
// runVersionSyncCheck checks that version.go matches default.nix.
func runVersionSyncCheck() CheckResult {
command := "Compare cmd/bd/version.go and default.nix"
// Read version.go
versionGoContent, err := os.ReadFile("cmd/bd/version.go")
if err != nil {
return CheckResult{
Name: "Version sync",
Passed: false,
Skipped: true,
Output: fmt.Sprintf("Cannot read cmd/bd/version.go: %v", err),
Command: command,
}
}
// Extract version from version.go
// Pattern: Version = "X.Y.Z"
versionGoRe := regexp.MustCompile(`Version\s*=\s*"([^"]+)"`)
versionGoMatch := versionGoRe.FindSubmatch(versionGoContent)
if versionGoMatch == nil {
return CheckResult{
Name: "Version sync",
Passed: false,
Skipped: true,
Output: "Cannot parse version from version.go",
Command: command,
}
}
goVersion := string(versionGoMatch[1])
// Read default.nix
nixContent, err := os.ReadFile("default.nix")
if err != nil {
// No nix file = skip version check (not an error)
return CheckResult{
Name: "Version sync",
Passed: true,
Skipped: true,
Output: "default.nix not found (skipping nix version check)",
Command: command,
}
}
// Extract version from default.nix
// Pattern: version = "X.Y.Z";
nixRe := regexp.MustCompile(`version\s*=\s*"([^"]+)"`)
nixMatch := nixRe.FindSubmatch(nixContent)
if nixMatch == nil {
return CheckResult{
Name: "Version sync",
Passed: false,
Skipped: true,
Output: "Cannot parse version from default.nix",
Command: command,
}
}
nixVersion := string(nixMatch[1])
if goVersion != nixVersion {
return CheckResult{
Name: "Version sync",
Passed: false,
Output: fmt.Sprintf("Version mismatch: version.go=%s, default.nix=%s", goVersion, nixVersion),
Command: command,
}
}
return CheckResult{
Name: "Version sync",
Passed: true,
Output: fmt.Sprintf("Versions match: %s", goVersion),
Command: command,
}
}
// truncateOutput truncates output to maxLen characters, adding ellipsis if truncated.
func truncateOutput(s string, maxLen int) string {
if len(s) <= maxLen {
return strings.TrimSpace(s)
}
return strings.TrimSpace(s[:maxLen]) + "\n... (truncated)"
}