summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBrendan Creane <bcreane@gmail.com>2026-01-26 17:15:27 -0800
committerBrendan Creane <bcreane@gmail.com>2026-02-02 16:23:12 -0800
commit9e3c662037139d3da6d0c1480c4d14017f8b7811 (patch)
tree19836206e81a0ff0fbe5706de93f875396e71a16
parent8736fbb754e7f6ce1cc391b7013ce7e184504faa (diff)
downloadtailscale-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.go273
-rw-r--r--cmd/mkchglog/main_test.go76
-rw-r--r--cmd/mkpkg/main.go2
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{