Files
net/tools/upgrade/main.go
deadprogram e82bdb20d3 Replace upgrade.sh with Go-based upgrade tool in tools/upgrade/
Rewrite the bash upgrade script as a standalone Go program for better
portability and maintainability. The tool preserves all existing behavior:
three-category file classification, dry-run mode, Go source resolution
(gvm/GOROOT/download), 3-way merge via diff3, and colored output.

Build: cd tools/upgrade && go build -o upgrade .
Run:   ./tools/upgrade/upgrade --dry-run

Signed-off-by: deadprogram <ron@hybridgroup.com>
2026-04-17 10:55:55 +02:00

788 lines
23 KiB
Go

package main
import (
"archive/tar"
"bufio"
"compress/gzip"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
)
// ── Defaults ────────────────────────────────────────────────────────────────
const (
defaultCurVersion = "1.21.4"
defaultUpstreamVersion = "1.26.2"
)
// ── Color helpers ───────────────────────────────────────────────────────────
const (
colorRed = "\033[0;31m"
colorGreen = "\033[0;32m"
colorYellow = "\033[1;33m"
colorBlue = "\033[0;34m"
colorReset = "\033[0m"
)
func info(msg string, args ...any) {
fmt.Fprintf(os.Stderr, colorBlue+"[INFO]"+colorReset+" "+msg+"\n", args...)
}
func success(msg string, args ...any) {
fmt.Fprintf(os.Stderr, colorGreen+"[OK]"+colorReset+" "+msg+"\n", args...)
}
func warn(msg string, args ...any) {
fmt.Fprintf(os.Stderr, colorYellow+"[WARN]"+colorReset+" "+msg+"\n", args...)
}
func errorf(msg string, args ...any) {
fmt.Fprintf(os.Stderr, colorRed+"[ERROR]"+colorReset+" "+msg+"\n", args...)
}
// ── File classification ─────────────────────────────────────────────────────
// Files entirely new to TinyGo (not from upstream) — never overwrite these.
var tinygoOnlyFiles = map[string]bool{
"netdev.go": true,
"tlssock.go": true,
"README.md": true,
"LICENSE": true,
}
// Files that are straight copies from upstream (no TINYGO modifications).
var unmodifiedFiles = []string{
"ip.go",
"mac.go",
"mac_test.go",
"parse.go",
"pipe.go",
"http/clone.go",
"http/cookie.go",
"http/fs.go",
"http/http.go",
"http/jar.go",
"http/method.go",
"http/sniff.go",
"http/status.go",
"http/internal/ascii/print.go",
"http/internal/ascii/print_test.go",
"http/internal/chunked.go",
"http/internal/chunked_test.go",
"http/httptest/recorder.go",
"http/httputil/dump.go",
"http/httputil/httputil.go",
"http/httputil/persist.go",
"http/httputil/reverseproxy.go",
"http/pprof/pprof.go",
}
// Files copied AND modified for TinyGo (contain // TINYGO markers).
// These need 3-way merge: upstream changes applied to TinyGo-modified version.
var modifiedFiles = []string{
"dial.go",
"interface.go",
"iprawsock.go",
"ipsock.go",
"lookup.go",
"lookup_unix.go",
"lookup_windows.go",
"net.go",
"tcpsock.go",
"udpsock.go",
"unixsock.go",
"http/client.go",
"http/header.go",
"http/pattern.go",
"http/request.go",
"http/response.go",
"http/server.go",
"http/transfer.go",
"http/transport.go",
"http/httptest/httptest.go",
"http/httptest/server.go",
"http/httptrace/trace.go",
}
var modifiedFilesSet map[string]bool
func init() {
modifiedFilesSet = make(map[string]bool, len(modifiedFiles))
for _, f := range modifiedFiles {
modifiedFilesSet[f] = true
}
}
// ── Map TinyGo paths to upstream Go stdlib paths ───────────────────────────
func upstreamPath(file string) string {
return "src/net/" + file
}
// ── Detect per-file CUR version from TINYGO header comment ─────────────────
var versionRe = regexp.MustCompile(`Go (\d+\.\d+\.\d+)`)
func detectCurVersion(filePath, fallback string) string {
f, err := os.Open(filePath)
if err != nil {
return fallback
}
defer f.Close()
scanner := bufio.NewScanner(f)
if scanner.Scan() {
if m := versionRe.FindStringSubmatch(scanner.Text()); m != nil {
return m[1]
}
}
return fallback
}
// ── Resolve Go source trees ────────────────────────────────────────────────
func resolveGoSource(version, workDir string) (string, error) {
// Try gvm install
gvmPath := filepath.Join(os.Getenv("HOME"), ".gvm", "gos", "go"+version)
if isDir(filepath.Join(gvmPath, "src", "net")) {
return gvmPath, nil
}
// Try current GOROOT if active Go version matches
goroot, _ := execOutput("go", "env", "GOROOT")
goroot = strings.TrimSpace(goroot)
if goroot != "" && isDir(filepath.Join(goroot, "src", "net")) {
activeVer, _ := execOutput("go", "version")
if m := versionRe.FindStringSubmatch(activeVer); m != nil && m[1] == version {
return goroot, nil
}
}
// Download
return downloadGoSource(version, workDir)
}
func downloadGoSource(version, workDir string) (string, error) {
destDir := filepath.Join(workDir, "go"+version)
if isDir(filepath.Join(destDir, "src", "net")) {
info("Go %s source already cached at %s", version, destDir)
return destDir, nil
}
url := fmt.Sprintf("https://go.dev/dl/go%s.src.tar.gz", version)
tarball := filepath.Join(workDir, fmt.Sprintf("go%s.src.tar.gz", version))
if err := os.MkdirAll(workDir, 0o755); err != nil {
return "", fmt.Errorf("creating work dir: %w", err)
}
if _, err := os.Stat(tarball); os.IsNotExist(err) {
info("Downloading Go %s source from %s ...", version, url)
if err := downloadFile(tarball, url); err != nil {
return "", fmt.Errorf("downloading %s: %w", url, err)
}
}
info("Extracting Go %s source...", version)
if err := extractTarGz(tarball, destDir); err != nil {
return "", fmt.Errorf("extracting tarball: %w", err)
}
success("Go %s source ready at %s", version, destDir)
return destDir, nil
}
func downloadFile(dest, url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d for %s", resp.StatusCode, url)
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func extractTarGz(tarball, destDir string) error {
f, err := os.Open(tarball)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
// Strip first component ("go/") to match --strip-components=1
parts := strings.SplitN(hdr.Name, "/", 2)
if len(parts) < 2 || parts[1] == "" {
continue
}
target := filepath.Join(destDir, parts[1])
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode))
if err != nil {
return err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return err
}
out.Close()
}
}
return nil
}
// ── Process unmodified (straight copy) files ────────────────────────────────
type summaryEntry struct {
status string
file string
detail string
}
type upgrader struct {
curVersion string
upstreamVersion string
dryRun bool
tinygoNetDir string
workDir string
reportDir string
curRoot string
upstreamRoot string
summary []summaryEntry
}
func (u *upgrader) addSummary(status, file, detail string) {
u.summary = append(u.summary, summaryEntry{status, file, detail})
}
func (u *upgrader) processUnmodified(file string) {
upath := upstreamPath(file)
upstreamFile := filepath.Join(u.upstreamRoot, upath)
tinygoFile := filepath.Join(u.tinygoNetDir, file)
if !fileExists(upstreamFile) {
warn("MISSING in upstream %s: %s", u.upstreamVersion, upath)
u.addSummary("MISSING_UPSTREAM", file, "")
return
}
curFile := filepath.Join(u.curRoot, upath)
if fileExists(curFile) && filesEqual(curFile, upstreamFile) {
u.addSummary("UNCHANGED", file, "")
return
}
if u.dryRun {
info("[DRY-RUN] Would replace %s with upstream %s copy", file, u.upstreamVersion)
if fileExists(curFile) {
diffPath := filepath.Join(u.reportDir, "diffs", file+".diff")
writeDiff(curFile, upstreamFile, diffPath)
lines := countLines(diffPath)
info(" Upstream diff: %d lines (see .upgrade-report/diffs/%s.diff)", lines, file)
}
u.addSummary("WOULD_COPY", file, "")
} else {
header := firstLine(tinygoFile)
if strings.HasPrefix(header, "// TINYGO") {
oldVer := detectCurVersion(tinygoFile, u.curVersion)
upstreamContent, err := os.ReadFile(upstreamFile)
if err != nil {
errorf("reading upstream file %s: %v", upstreamFile, err)
return
}
// Update version in header and prepend to upstream content (minus first line)
newHeader := strings.Replace(header, "Go "+oldVer, "Go "+u.upstreamVersion, 1)
// Find the first newline in upstream content to skip its first line
upstreamLines := string(upstreamContent)
if idx := strings.Index(upstreamLines, "\n"); idx >= 0 {
upstreamLines = upstreamLines[idx:]
}
if err := os.WriteFile(tinygoFile, []byte(newHeader+upstreamLines), 0o644); err != nil {
errorf("writing %s: %v", tinygoFile, err)
return
}
} else {
if err := copyFile(upstreamFile, tinygoFile); err != nil {
errorf("copying %s: %v", file, err)
return
}
}
u.addSummary("COPIED", file, "")
success("Copied %s from upstream %s", file, u.upstreamVersion)
}
}
// ── Process modified (3-way merge) files ────────────────────────────────────
func (u *upgrader) processModified(file string) {
upath := upstreamPath(file)
upstreamFile := filepath.Join(u.upstreamRoot, upath)
tinygoFile := filepath.Join(u.tinygoNetDir, file)
if !fileExists(upstreamFile) {
warn("MISSING in upstream %s: %s", u.upstreamVersion, upath)
u.addSummary("MISSING_UPSTREAM", file, "")
return
}
// Detect the actual CUR version for this specific file
fileCurVersion := detectCurVersion(tinygoFile, u.curVersion)
fileCurRoot := u.curRoot
if fileCurVersion != u.curVersion {
info("%s: based on Go %s (not default %s)", file, fileCurVersion, u.curVersion)
var err error
fileCurRoot, err = resolveGoSource(fileCurVersion, u.workDir)
if err != nil {
errorf("resolving Go %s source: %v", fileCurVersion, err)
return
}
}
curFile := filepath.Join(fileCurRoot, upath)
if !fileExists(curFile) {
warn("MISSING in CUR %s: %s — can only diff against current TinyGo version", fileCurVersion, upath)
u.addSummary("MISSING_CUR", file, "")
diffPath := filepath.Join(u.reportDir, "diffs", file+".upstream-vs-tinygo.diff")
writeDiff(tinygoFile, upstreamFile, diffPath)
return
}
// Check if upstream even changed this file
if filesEqual(curFile, upstreamFile) {
u.addSummary("UNCHANGED", file, "")
return
}
// Generate upstream diff (what changed from CUR -> UPSTREAM in official Go)
upstreamDiffPath := filepath.Join(u.reportDir, "diffs", file+".upstream-changes.diff")
writeDiff(curFile, upstreamFile, upstreamDiffPath)
// Generate current tinygo diff (what TinyGo changed from CUR)
tinygoDiffPath := filepath.Join(u.reportDir, "diffs", file+".tinygo-changes.diff")
writeDiff(curFile, tinygoFile, tinygoDiffPath)
upstreamLines := countLines(upstreamDiffPath)
if u.dryRun {
info("[DRY-RUN] Would 3-way merge %s (Go %s → %s)", file, fileCurVersion, u.upstreamVersion)
info(" Upstream changes: %d diff lines", upstreamLines)
info(" See: .upgrade-report/diffs/%s.upstream-changes.diff", file)
info(" See: .upgrade-report/diffs/%s.tinygo-changes.diff", file)
u.addSummary("WOULD_MERGE", file, fmt.Sprintf("(%d upstream diff lines)", upstreamLines))
} else {
// Attempt 3-way merge using diff3
mergedPath := filepath.Join(u.reportDir, "merged", file)
if err := os.MkdirAll(filepath.Dir(mergedPath), 0o755); err != nil {
errorf("creating merge dir: %v", err)
return
}
// diff3 -m tinygoFile curFile upstreamFile
cmd := exec.Command("diff3", "-m", tinygoFile, curFile, upstreamFile)
output, err := cmd.Output()
// Write merged output regardless of exit code
if writeErr := os.WriteFile(mergedPath, output, 0o644); writeErr != nil {
errorf("writing merged file: %v", writeErr)
return
}
if err == nil {
// Clean merge
if cpErr := copyFile(mergedPath, tinygoFile); cpErr != nil {
errorf("copying merged file: %v", cpErr)
return
}
// Update version header
updateVersionHeader(tinygoFile, u.upstreamVersion)
u.addSummary("MERGED_CLEAN", file, "")
success("Merged %s cleanly (Go %s → %s)", file, fileCurVersion, u.upstreamVersion)
} else {
// Merge conflicts
conflictedPath := tinygoFile + ".conflicted"
if cpErr := copyFile(mergedPath, conflictedPath); cpErr != nil {
errorf("copying conflicted file: %v", cpErr)
return
}
conflicts := countOccurrences(mergedPath, "<<<<<<<")
u.addSummary("CONFLICTS", file, fmt.Sprintf("(%d conflicts)", conflicts))
warn("CONFLICTS in %s: %d conflict(s)", file, conflicts)
warn(" Conflicted merge saved to: %s.conflicted", file)
warn(" Upstream changes: .upgrade-report/diffs/%s.upstream-changes.diff", file)
warn(" TinyGo changes: .upgrade-report/diffs/%s.tinygo-changes.diff", file)
}
}
}
func updateVersionHeader(filePath, newVersion string) {
data, err := os.ReadFile(filePath)
if err != nil {
return
}
lines := strings.SplitN(string(data), "\n", 2)
if len(lines) < 1 {
return
}
if m := versionRe.FindStringSubmatch(lines[0]); m != nil {
lines[0] = strings.Replace(lines[0], "Go "+m[1], "Go "+newVersion, 1)
os.WriteFile(filePath, []byte(strings.Join(lines, "\n")), 0o644)
}
}
// ── Summary ─────────────────────────────────────────────────────────────────
func (u *upgrader) printSummary() {
counts := make(map[string]int)
for _, e := range u.summary {
counts[e.status]++
}
fmt.Println()
fmt.Println("═══════════════════════════════════════════════════════════════")
fmt.Println(" Summary")
fmt.Println("═══════════════════════════════════════════════════════════════")
fmt.Println()
fmt.Printf(" Unchanged (no upstream changes): %d\n", counts["UNCHANGED"])
if u.dryRun {
fmt.Printf(" Would copy (unmodified): %d\n", counts["WOULD_COPY"])
fmt.Printf(" Would merge (modified): %d\n", counts["WOULD_MERGE"])
} else {
fmt.Printf(" Copied (unmodified): %d\n", counts["COPIED"])
fmt.Printf(" Merged cleanly: %d\n", counts["MERGED_CLEAN"])
fmt.Printf(" CONFLICTS (need manual fix): %d\n", counts["CONFLICTS"])
}
if counts["MISSING_UPSTREAM"] > 0 {
fmt.Printf(" Missing in upstream: %d\n", counts["MISSING_UPSTREAM"])
}
if counts["MISSING_CUR"] > 0 {
fmt.Printf(" Missing in CUR baseline: %d\n", counts["MISSING_CUR"])
}
if counts["CONFLICTS"] > 0 {
fmt.Println()
warn("Files with merge conflicts:")
for _, e := range u.summary {
if e.status == "CONFLICTS" {
fmt.Printf(" %s %s\n", e.file, e.detail)
}
}
}
if u.dryRun && counts["WOULD_MERGE"] > 0 {
fmt.Println()
info("Modified files that need merging:")
for _, e := range u.summary {
if e.status == "WOULD_MERGE" {
fmt.Printf(" %s %s\n", e.file, e.detail)
}
}
}
fmt.Println()
fmt.Printf(" Full report: %s/\n", u.reportDir)
fmt.Printf(" Diffs: %s/diffs/\n", u.reportDir)
if !u.dryRun {
fmt.Printf(" Merged: %s/merged/\n", u.reportDir)
}
fmt.Println()
if u.dryRun {
info("Dry run complete. Review the report, then run without --dry-run to apply.")
} else {
info("Upgrade applied. Review changes, resolve any conflicts, then test.")
fmt.Println()
fmt.Println("Next steps:")
fmt.Println(" 1. Review and resolve any .conflicted files")
fmt.Println(" 2. Check for files missing from upstream (may have been renamed/removed)")
fmt.Println(" 3. Verify TINYGO comments are preserved")
fmt.Println(" 4. Test with TinyGo example/net examples")
fmt.Println(" 5. Update README.md version references")
}
}
// ── Write summary.txt ───────────────────────────────────────────────────────
func (u *upgrader) writeSummaryFile() {
path := filepath.Join(u.reportDir, "summary.txt")
f, err := os.Create(path)
if err != nil {
return
}
defer f.Close()
for _, e := range u.summary {
if e.detail != "" {
fmt.Fprintf(f, "%s %s %s\n", e.status, e.file, e.detail)
} else {
fmt.Fprintf(f, "%s %s\n", e.status, e.file)
}
}
}
// ── Utility functions ───────────────────────────────────────────────────────
func isDir(path string) bool {
fi, err := os.Stat(path)
return err == nil && fi.IsDir()
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func filesEqual(a, b string) bool {
dataA, errA := os.ReadFile(a)
dataB, errB := os.ReadFile(b)
if errA != nil || errB != nil {
return false
}
return string(dataA) == string(dataB)
}
func firstLine(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
if scanner.Scan() {
return scanner.Text()
}
return ""
}
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
}
func writeDiff(fileA, fileB, outPath string) {
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return
}
cmd := exec.Command("diff", "-u", fileA, fileB)
// diff returns exit code 1 when files differ, which is expected
output, _ := cmd.CombinedOutput()
os.WriteFile(outPath, output, 0o644)
}
func countLines(path string) int {
f, err := os.Open(path)
if err != nil {
return 0
}
defer f.Close()
count := 0
scanner := bufio.NewScanner(f)
for scanner.Scan() {
count++
}
return count
}
func countOccurrences(path, needle string) int {
data, err := os.ReadFile(path)
if err != nil {
return 0
}
return strings.Count(string(data), needle)
}
func execOutput(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
out, err := cmd.Output()
return string(out), err
}
// ── Main ────────────────────────────────────────────────────────────────────
func main() {
curVersion := flag.String("cur", defaultCurVersion, "Current Go version the TinyGo net package is based on")
upstreamVersion := flag.String("upstream", defaultUpstreamVersion, "Target upstream Go version to upgrade to")
dryRun := flag.Bool("dry-run", false, "Preview what would change without modifying files")
singleFile := flag.String("file", "", "Process only a single file")
help := flag.Bool("help", false, "Show help")
flag.Parse()
if *help {
fmt.Println(`TinyGo "net" package upgrade tool
Automates Step 1 & Step 2 of the README upgrade process:
Step 1: Backport differences from Go UPSTREAM to current CUR
Step 2: Generate comparison report of NEW vs UPSTREAM
Usage:
upgrade [--dry-run] [--cur VERSION] [--upstream VERSION] [--file FILE]
Examples:
upgrade --dry-run # Preview what would change
upgrade --cur 1.21.4 --upstream 1.26.2 # Perform the upgrade
upgrade --dry-run --file dial.go # Preview single file`)
os.Exit(0)
}
// Determine the TinyGo net directory (two levels up from tools/upgrade/)
exe, err := os.Executable()
if err != nil {
// Fall back to working directory
exe, _ = os.Getwd()
} else {
exe = filepath.Dir(filepath.Dir(filepath.Dir(exe)))
}
// Also support running via "go run" — use working directory if exe path looks wrong
tinygoNetDir := exe
if !fileExists(filepath.Join(tinygoNetDir, "netdev.go")) {
tinygoNetDir, _ = os.Getwd()
}
if !fileExists(filepath.Join(tinygoNetDir, "netdev.go")) {
errorf("Cannot find TinyGo net directory. Run from the net package root or build the tool first.")
os.Exit(1)
}
u := &upgrader{
curVersion: *curVersion,
upstreamVersion: *upstreamVersion,
dryRun: *dryRun,
tinygoNetDir: tinygoNetDir,
workDir: filepath.Join(tinygoNetDir, ".upgrade-work"),
reportDir: filepath.Join(tinygoNetDir, ".upgrade-report"),
}
fmt.Println()
fmt.Println("═══════════════════════════════════════════════════════════════")
fmt.Println(" TinyGo net package upgrade")
fmt.Printf(" CUR: Go %s → UPSTREAM: Go %s\n", u.curVersion, u.upstreamVersion)
if u.dryRun {
fmt.Println(" Mode: DRY RUN (no files will be changed)")
} else {
fmt.Println(" Mode: APPLY CHANGES")
}
fmt.Println("═══════════════════════════════════════════════════════════════")
fmt.Println()
// Set up report directory
os.RemoveAll(u.reportDir)
os.MkdirAll(filepath.Join(u.reportDir, "diffs"), 0o755)
os.MkdirAll(filepath.Join(u.reportDir, "merged"), 0o755)
// Resolve Go source trees
info("Resolving Go %s source...", u.curVersion)
u.curRoot, err = resolveGoSource(u.curVersion, u.workDir)
if err != nil {
errorf("Failed to resolve Go %s: %v", u.curVersion, err)
os.Exit(1)
}
info(" → %s", u.curRoot)
info("Resolving Go %s source...", u.upstreamVersion)
u.upstreamRoot, err = resolveGoSource(u.upstreamVersion, u.workDir)
if err != nil {
errorf("Failed to resolve Go %s: %v", u.upstreamVersion, err)
os.Exit(1)
}
info(" → %s", u.upstreamRoot)
fmt.Println()
// Build file list
var filesToProcess []string
if *singleFile != "" {
filesToProcess = []string{*singleFile}
} else {
filesToProcess = append(filesToProcess, unmodifiedFiles...)
filesToProcess = append(filesToProcess, modifiedFiles...)
}
// Deduplicate and sort for deterministic order
seen := make(map[string]bool)
var deduped []string
for _, f := range filesToProcess {
if !seen[f] {
seen[f] = true
deduped = append(deduped, f)
}
}
sort.Strings(deduped)
filesToProcess = deduped
total := len(filesToProcess)
for idx, file := range filesToProcess {
fmt.Printf(colorBlue+"[%d/%d]"+colorReset+" Processing %s...\n", idx+1, total, file)
// Skip TinyGo-only files
if tinygoOnlyFiles[file] {
info(" Skipping (TinyGo-only file)")
u.addSummary("SKIPPED_TINYGO_ONLY", file, "")
continue
}
// Create diff directory structure
os.MkdirAll(filepath.Dir(filepath.Join(u.reportDir, "diffs", file)), 0o755)
if modifiedFilesSet[file] {
u.processModified(file)
} else {
u.processUnmodified(file)
}
}
u.writeSummaryFile()
u.printSummary()
}