diff options
| author | Brendan Creane <bcreane@gmail.com> | 2026-01-26 17:15:27 -0800 |
|---|---|---|
| committer | Brendan Creane <bcreane@gmail.com> | 2026-02-02 16:23:12 -0800 |
| commit | 9e3c662037139d3da6d0c1480c4d14017f8b7811 (patch) | |
| tree | 19836206e81a0ff0fbe5706de93f875396e71a16 | |
| parent | 8736fbb754e7f6ce1cc391b7013ce7e184504faa (diff) | |
| download | tailscale-brendan/convert-mdx-release-notes-to-goreleaser-chlog.tar.xz tailscale-brendan/convert-mdx-release-notes-to-goreleaser-chlog.zip | |
cmd: add mkchglog and --changelog flag to mkpkgbrendan/convert-mdx-release-notes-to-goreleaser-chlog
Add a new mkchglog utility to cmd/ and implement a --changelog flag
in mkpkg to support structured changelog ingestion.
The mkchglog utility parses Tailscale's internal MDX changelogs,
extracting Linux-relevant changes and formatting them into a structured
YAML schema. This allows mkpkg to automatically populate Debian and RPM
changelog metadata during the build process.
The utility supports:
- Authenticated MDX fetching from GitHub.
- YAML schema generation for target-specific metadata (urgency, dist).
- Automated cleaning of GitHub-specific noise from bullet points.
Closes #314
Signed-off-by: Brendan Creane <bcreane@gmail.com>
| -rw-r--r-- | cmd/mkchglog/main.go | 273 | ||||
| -rw-r--r-- | cmd/mkchglog/main_test.go | 76 | ||||
| -rw-r--r-- | cmd/mkpkg/main.go | 2 |
3 files changed, 351 insertions, 0 deletions
diff --git a/cmd/mkchglog/main.go b/cmd/mkchglog/main.go new file mode 100644 index 000000000..22d3bfb60 --- /dev/null +++ b/cmd/mkchglog/main.go @@ -0,0 +1,273 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Package main implements mkchglog, a tool to convert Tailscale's internal MDX +// changelogs into a structured YAML format for release automation tools like +// goreleaser/chlog or mkpkg. +// +// The tool performs the following: +// 1. Extracts 'clientVersion' from the YAML frontmatter to determine the version. +// 2. Extracts bullet points specifically from the "##### All Platforms" +// and "##### Linux" sections. +// 3. Cleans entries by removing GitHub PR links (#1234), user tags (@user), +// markdown link syntax [text](url), stripping backticks, brackets, and kb-links. +// 4. Formats the output into a YAML schema compatible with mkpkg and goreleaser, +// supporting target-specific configurations (e.g., deb, rpm). +// +// Authentication: +// For private repositories, the tool uses the GITHUB_TOKEN or TS_GITHUB_TOKEN +// environment variables. +// +// Usage: +// +// export GITHUB_TOKEN=$(gh auth token) +// go run ./cmd/mkchglog [flags] <file_path | github_url> +// +// Flags: +// +// --target Target package type (e.g., "deb", "rpm"). Defaults to "deb". +// --date Release date in YYYY-MM-DD format. Defaults to current time. +// --urgency Release urgency (e.g., "low", "medium", "high"). Defaults to "medium". +// --dist Target distribution (e.g., "stable", "unstable"). Defaults to "unstable". +// --maint Packager identification string. +// --debug Enable verbose logging to stderr. +// +// Example: +// +// go run ./cmd/mkchglog --target deb --dist stable --date 2026-01-27 \ +// https://raw.githubusercontent.com/tailscale/tailscale-www/main/nextjs/src/data/changelog/2026/2026-01-27-client.mdx +package main + +import ( + "bufio" + "flag" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + "time" + "unicode" + + "gopkg.in/yaml.v3" +) + +var ( + debug = flag.Bool("debug", false, "enable debug logging") + dateArg = flag.String("date", "", "release date (YYYY-MM-DD)") + target = flag.String("target", "deb", "target package type (e.g., deb, rpm)") + urgency = flag.String("urgency", "medium", "release urgency") + dist = flag.String("dist", "unstable", "target distribution") + maint = flag.String("maint", "Tailscale Inc <info@tailscale.com>", "packager identification") + + rePR = regexp.MustCompile(`\s*\(#\d+\)`) + reUser = regexp.MustCompile(`\s*@[\w-]+`) + reLinks = regexp.MustCompile(`\[([^\]]+)\]\([^\)]+\)`) + reKbs = regexp.MustCompile(`\[kb-[\w-]+\]`) +) + +// OutputSchema matches a single version entry in the YAML list structure +// expected by chlog/mkpkg. +type OutputSchema struct { + Deb *DebConfig `yaml:"deb,omitempty"` + Semver string `yaml:"semver"` + Date string `yaml:"date"` + Packager string `yaml:"packager"` + Urgency string `yaml:"urgency"` + Distribution string `yaml:"distribution"` + Changes []ChangeEntry `yaml:"changes"` +} + +type DebConfig struct { + Urgency string `yaml:"urgency"` + Distributions []string `yaml:"distributions"` +} + +type ChangeEntry struct { + Note string `yaml:"note"` +} + +type changelogData struct { + Version string + Items []string +} + +func main() { + flag.Parse() + args := flag.Args() + + if len(args) < 1 { + fmt.Fprintf(os.Stderr, "usage: mkchglog [flags] <file_path_or_github_url>\n") + os.Exit(1) + } + + releaseDate := time.Now() + if *dateArg != "" { + if parsed, err := time.Parse("2006-01-02", *dateArg); err == nil { + releaseDate = parsed + } else { + fmt.Fprintf(os.Stderr, "invalid date format: %v\n", err) + os.Exit(1) + } + } + + input := args[0] + var rc io.ReadCloser + var err error + + if strings.HasPrefix(input, "http") { + rc, err = fetchURL(input) + } else { + rc, err = os.Open(input) + } + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + defer rc.Close() + + data, err := parseMDX(rc) + if err != nil { + fmt.Fprintf(os.Stderr, "parse error: %v\n", err) + os.Exit(1) + } + + // Build the single entry. + entry := OutputSchema{ + Semver: data.Version, + Date: releaseDate.Format(time.RFC3339), + Packager: *maint, + Urgency: *urgency, + Distribution: *dist, + } + + if *target == "deb" { + entry.Deb = &DebConfig{ + Urgency: *urgency, + Distributions: []string{*dist}, + } + } + + for _, item := range data.Items { + entry.Changes = append(entry.Changes, ChangeEntry{Note: item}) + } + + // Wrap in a slice so the output is a YAML list (-), as required by chlog. + out := []OutputSchema{entry} + + enc := yaml.NewEncoder(os.Stdout) + enc.SetIndent(2) + if err := enc.Encode(out); err != nil { + fmt.Fprintf(os.Stderr, "yaml encode error: %v\n", err) + os.Exit(1) + } +} + +func fetchURL(urlStr string) (io.ReadCloser, error) { + cleanURL := urlStr + if idx := strings.Index(cleanURL, "?"); idx != -1 { + cleanURL = cleanURL[:idx] + } + if strings.Contains(cleanURL, "github.com") && !strings.Contains(cleanURL, "raw.githubusercontent.com") { + cleanURL = strings.Replace(cleanURL, "github.com", "raw.githubusercontent.com", 1) + cleanURL = strings.Replace(cleanURL, "/blob/", "/", 1) + } + + if *debug { + fmt.Fprintf(os.Stderr, "[DEBUG] Fetching URL: %s\n", cleanURL) + } + + req, _ := http.NewRequest("GET", cleanURL, nil) + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + token = os.Getenv("TS_GITHUB_TOKEN") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("http error: %s", resp.Status) + } + return resp.Body, nil +} + +func parseMDX(r io.Reader) (*changelogData, error) { + data := &changelogData{Version: "unknown"} + var ( + recording bool + inFrontmatter bool + frontmatterCount int + scanner = bufio.NewScanner(r) + ) + + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + if trimmed == "---" { + frontmatterCount++ + inFrontmatter = (frontmatterCount == 1) + continue + } + if inFrontmatter { + if strings.HasPrefix(trimmed, "clientVersion:") { + data.Version = strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, "clientVersion:")), `"'`) + } + continue + } + if strings.HasPrefix(trimmed, "#") { + lower := strings.ToLower(trimmed) + if strings.Contains(lower, "all platforms") || strings.Contains(lower, "linux") { + recording = true + if *debug { + fmt.Fprintf(os.Stderr, "[DEBUG] START recording at header: %q\n", trimmed) + } + } else if strings.Contains(lower, "ios") || strings.Contains(lower, "macos") || + strings.Contains(lower, "windows") || strings.Contains(lower, "android") { + if recording && *debug { + fmt.Fprintf(os.Stderr, "[DEBUG] STOP recording at header: %q\n", trimmed) + } + recording = false + } + continue + } + if recording && isBullet(trimmed) { + clean := cleanLine(stripBullet(trimmed)) + if clean != "" { + data.Items = append(data.Items, clean) + } + } + } + return data, scanner.Err() +} + +func isBullet(s string) bool { + if len(s) < 2 { + return false + } + return (s[0] == '*' || s[0] == '-' || s[0] == '+') && unicode.IsSpace(rune(s[1])) +} + +func stripBullet(s string) string { + return strings.TrimSpace(strings.TrimLeftFunc(s, func(r rune) bool { + return r == '*' || r == '-' || r == '+' || unicode.IsSpace(r) + })) +} + +func cleanLine(s string) string { + s = rePR.ReplaceAllString(s, "") + s = reUser.ReplaceAllString(s, "") + s = reLinks.ReplaceAllString(s, "$1") + s = reKbs.ReplaceAllString(s, "") + s = strings.ReplaceAll(s, "`", "") + s = strings.ReplaceAll(s, "[", "") + s = strings.ReplaceAll(s, "]", "") + return strings.TrimSpace(s) +} diff --git a/cmd/mkchglog/main_test.go b/cmd/mkchglog/main_test.go new file mode 100644 index 000000000..a543eb912 --- /dev/null +++ b/cmd/mkchglog/main_test.go @@ -0,0 +1,76 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "strings" + "testing" +) + +func TestParseMDX(t *testing.T) { + input := `--- +clientVersion: "1.94.1" +--- +##### All Platforms +* New: Core fix (#123) @user +* Changed: Improved [performance](https://tailscale.com) +##### Linux +* Fixed: Systemd fix [kb-article] +* Something uncategorized +##### Windows +* Should be ignored +` + r := strings.NewReader(input) + data, err := parseMDX(r) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + // Verify Metadata + if data.Version != "1.94.1" { + t.Errorf("Expected version 1.94.1, got %q", data.Version) + } + + // Verify Items are collected and cleaned + expected := []string{ + "New: Core fix", + "Changed: Improved performance", + "Fixed: Systemd fix", + "Something uncategorized", + } + + if len(data.Items) != len(expected) { + t.Fatalf("Expected %d items, got %d", len(expected), len(data.Items)) + } + + for i, v := range data.Items { + if v != expected[i] { + t.Errorf("At index %d: expected %q, got %q", i, expected[i], v) + } + } +} + +func TestCleanLine(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"strip PR", "Fix bug (#123)", "Fix bug"}, + {"strip user", "Fix by @user", "Fix by"}, + {"strip markdown link", "See [docs](https://tailscale.com)", "See docs"}, + {"strip brackets", "[TKA] is [stable]", "TKA is stable"}, + {"strip kb links", "Check [kb-article-name]", "Check"}, + {"strip backticks", "Use `tailscale up`", "Use tailscale up"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cleanLine(tt.input) + if got != tt.expected { + t.Errorf("cleanLine(%q) = %q; want %q", tt.input, got, tt.expected) + } + }) + } +} diff --git a/cmd/mkpkg/main.go b/cmd/mkpkg/main.go index 6f4de7e29..0333c2c59 100644 --- a/cmd/mkpkg/main.go +++ b/cmd/mkpkg/main.go @@ -63,6 +63,7 @@ func main() { replaces := flag.String("replaces", "", "package which this package replaces, if any") depends := flag.String("depends", "", "comma-separated list of packages this package depends on") recommends := flag.String("recommends", "", "comma-separated list of packages this package recommends") + changelog := flag.String("changelog", "", "path to changelog.yaml file") flag.Parse() filesList, err := parseFiles(*regularFiles, files.TypeFile) @@ -88,6 +89,7 @@ func main() { Description: *description, Homepage: "https://www.tailscale.com", License: "MIT", + Changelog: *changelog, Overridables: nfpm.Overridables{ Contents: contents, Scripts: nfpm.Scripts{ |
