diff --git a/README.md b/README.md
index 498b437..e71b176 100644
--- a/README.md
+++ b/README.md
@@ -77,7 +77,7 @@ More end-to-end recipes → **[docs/EXAMPLES.md](docs/EXAMPLES.md)**
| `mask` | Hide secrets in logs (regex patterns, partial reveal, GitHub `::add-mask::`) | [↗](docs/COMMANDS.md#mask) |
| `transform` | base64, URL-encode, case conversion, regex replace, templates, hash, slug | [↗](docs/COMMANDS.md#transform) |
| `summary` | Append markdown / tables / badges / collapsibles to `$GITHUB_STEP_SUMMARY` | [↗](docs/COMMANDS.md#summary) |
-| `assert` | Pipeline guards: env vars, files, JSON paths, semver, URL health | [↗](docs/COMMANDS.md#assert) |
+| `assert` | Pipeline guards: env vars, files, JSON paths, semver, URL health, version sync | [↗](docs/COMMANDS.md#assert) |
| `matrix` | Build GitHub Actions matrix JSON from dirs / files / JSON / Cartesian product | [↗](docs/COMMANDS.md#matrix) |
| `notify` | Slack / Discord / Teams / generic webhook with status formatting | [↗](docs/COMMANDS.md#notify) |
| `wait` | Poll a URL / TCP port / shell command until ready | [↗](docs/COMMANDS.md#wait) |
@@ -86,22 +86,25 @@ More end-to-end recipes → **[docs/EXAMPLES.md](docs/EXAMPLES.md)**
| `retry` | Run any command with attempt count, delay, backoff, exit-code filtering | [↗](docs/COMMANDS.md#retry) |
| `cache-key` | Deterministic SHA256 cache keys from files / globs / composite parts | [↗](docs/COMMANDS.md#cache-key) |
| `checksum` | Generate / verify release checksums for artifact files | [↗](docs/COMMANDS.md#checksum) |
-| `artifact` | Assert artifacts exist and generate size/SHA256 manifests | [↗](docs/COMMANDS.md#artifact) |
+| `artifact` | Assert artifacts exist (with size budgets) and generate size/SHA256 manifests | [↗](docs/COMMANDS.md#artifact) |
| `archive` | Pack, list, and unpack zip/tar/tar.gz/tar.xz/tar.zst archives | [↗](docs/COMMANDS.md#archive) |
| `git` | CI-friendly git metadata: ref, SHA, tags, dirty state | [↗](docs/COMMANDS.md#git) |
| `changelog` | Generate release notes from git commit ranges | [↗](docs/COMMANDS.md#changelog) |
| `config` | Resolve env-specific config maps; map branches to environments | [↗](docs/COMMANDS.md#config) |
| `parse` | Pull fenced code blocks / YAML / frontmatter out of issue bodies, PR comments, markdown | [↗](docs/COMMANDS.md#parse) |
| `comment` | Render, inspect, select, and amend hidden-anchor PR comments | [↗](docs/COMMANDS.md#comment) |
-| `json` / `yaml` | Get / set / del / deep-merge / convert / pretty / table on JSON, YAML, TOML, CSV | [↗](docs/COMMANDS.md#json) |
-| `render` | Render Go templates with a sprig-like FuncMap and stacked `--values` files | [↗](docs/COMMANDS.md#render) |
+| `json` / `yaml` | Get / set / del / semantic diff / deep-merge / convert / pretty / table on JSON, YAML, TOML, CSV — incl. multi-doc YAML addressing (`--doc`) | [↗](docs/COMMANDS.md#json) |
+| `render` | Render Go templates with a sprig-like FuncMap and stacked `--values` files; `--envsubst` mode | [↗](docs/COMMANDS.md#render) |
| `exec` | Unified retry + mask + tee + timeout command runner | [↗](docs/COMMANDS.md#exec) |
-| `http` | Curl-like HTTP requests, JSON extraction, uploads, and chained flows | [↗](docs/COMMANDS.md#http) |
+| `http` | Curl-like HTTP requests, JSON extraction, uploads, chained flows, and Link-header pagination | [↗](docs/COMMANDS.md#http) |
| `url parse` | Split a URL into `SCHEME / HOST / PORT / USER / PASSWORD / PATH / QUERY` env vars | [↗](docs/COMMANDS.md#url) |
| `image parse` | Split a container image ref into registry / repository / tag / digest | [↗](docs/COMMANDS.md#image) |
| `time` | RFC3339 / unix / tag / compact / iso timestamps; format conversion; arithmetic | [↗](docs/COMMANDS.md#time) |
| `port free` · `uuid` · `random` | Tiny generators for ephemeral resource names and ports | [↗](docs/COMMANDS.md#misc) |
| `doctor` | Diagnose CI platform, env-var contract, tool availability | [↗](docs/COMMANDS.md#doctor) |
+| `report` | Parse JUnit XML / lcov / cobertura into summaries, outputs, and threshold gates | [↗](docs/COMMANDS.md#report) |
+| `annotate` | Emit inline PR annotations (GitHub) or plain diagnostics; convert linter JSON | [↗](docs/COMMANDS.md#annotate) |
+| `lock` | File-based mutex: acquire / release / run with timeout and stale takeover | [↗](docs/COMMANDS.md#lock) |
Full flag reference → **[docs/COMMANDS.md](docs/COMMANDS.md)**
diff --git a/actions/annotate.go b/actions/annotate.go
new file mode 100644
index 0000000..1dcc6ef
--- /dev/null
+++ b/actions/annotate.go
@@ -0,0 +1,114 @@
+package actions
+
+import (
+ "fmt"
+
+ "github.com/AxeForging/pipekit/services"
+
+ "github.com/urfave/cli"
+)
+
+// AnnotateCommand returns the CI annotation command group.
+func AnnotateCommand() cli.Command {
+ levelCmd := func(level, usage string) cli.Command {
+ return cli.Command{
+ Name: level,
+ Usage: usage,
+ ArgsUsage: "MESSAGE",
+ Flags: annotateFlags(),
+ Action: func(c *cli.Context) error {
+ msg, err := firstArgOrErr(c, "MESSAGE")
+ if err != nil {
+ return err
+ }
+ a := services.Annotation{
+ Level: level,
+ File: c.String("file"),
+ Line: c.Int("line"),
+ EndLine: c.Int("end-line"),
+ Col: c.Int("col"),
+ Title: c.String("title"),
+ Message: msg,
+ }
+ fmt.Println(services.FormatAnnotation(a, annotatePlatform(c)))
+ return nil
+ },
+ }
+ }
+
+ return cli.Command{
+ Name: "annotate",
+ Usage: "emit CI annotations (inline PR comments on GitHub, plain lines elsewhere)",
+ Subcommands: []cli.Command{
+ levelCmd("error", "emit an error annotation"),
+ levelCmd("warning", "emit a warning annotation"),
+ levelCmd("notice", "emit a notice annotation"),
+ {
+ Name: "from-json",
+ Usage: "convert a JSON array of linter findings into annotations",
+ ArgsUsage: "[FILE]",
+ Flags: []cli.Flag{
+ cli.StringFlag{Name: "file-key", Value: "file", Usage: "field holding the file path"},
+ cli.StringFlag{Name: "line-key", Value: "line", Usage: "field holding the line number"},
+ cli.StringFlag{Name: "col-key", Value: "col", Usage: "field holding the column"},
+ cli.StringFlag{Name: "message-key", Value: "message", Usage: "field holding the message"},
+ cli.StringFlag{Name: "severity-key", Value: "severity", Usage: "field holding the severity"},
+ cli.StringFlag{Name: "title-key", Value: "title", Usage: "field holding the title"},
+ cli.StringFlag{Name: "platform", Usage: "force output platform: github-actions, plain (default: auto-detect)"},
+ cli.BoolFlag{Name: "fail-on-errors", Usage: "exit 1 when any finding normalizes to error level"},
+ },
+ Action: func(c *cli.Context) error {
+ data, err := readReportInput(c)
+ if err != nil {
+ return err
+ }
+ keys := services.AnnotationKeys{
+ File: c.String("file-key"),
+ Line: c.String("line-key"),
+ Col: c.String("col-key"),
+ Message: c.String("message-key"),
+ Severity: c.String("severity-key"),
+ Title: c.String("title-key"),
+ }
+ annotations, err := services.ParseAnnotationsJSON(data, keys)
+ if err != nil {
+ return err
+ }
+ platform := annotatePlatform(c)
+ errors := 0
+ for _, a := range annotations {
+ if services.NormalizeAnnotationLevel(a.Level) == "error" {
+ errors++
+ }
+ fmt.Println(services.FormatAnnotation(a, platform))
+ }
+ if c.Bool("fail-on-errors") && errors > 0 {
+ return cli.NewExitError(fmt.Sprintf("%d error finding(s)", errors), 1)
+ }
+ return nil
+ },
+ },
+ },
+ }
+}
+
+func annotateFlags() []cli.Flag {
+ return []cli.Flag{
+ cli.StringFlag{Name: "file, f", Usage: "source file the annotation points at"},
+ cli.IntFlag{Name: "line, l", Usage: "line number"},
+ cli.IntFlag{Name: "end-line", Usage: "end line for a range"},
+ cli.IntFlag{Name: "col", Usage: "column number"},
+ cli.StringFlag{Name: "title, t", Usage: "annotation title"},
+ cli.StringFlag{Name: "platform", Usage: "force output platform: github-actions, plain (default: auto-detect)"},
+ }
+}
+
+func annotatePlatform(c *cli.Context) string {
+ if p := c.String("platform"); p != "" {
+ if p == "github" {
+ return "github-actions"
+ }
+ return p
+ }
+ return services.DetectCI().Name
+}
diff --git a/actions/artifact.go b/actions/artifact.go
index 1ca3681..2c68f6f 100644
--- a/actions/artifact.go
+++ b/actions/artifact.go
@@ -38,7 +38,7 @@ func ArtifactCommand() cli.Command {
}
out += "\n"
if path := c.String("output"); path != "" {
- return os.WriteFile(path, []byte(out), 0644)
+ return os.WriteFile(path, []byte(out), 0o644)
}
fmt.Print(out)
return nil
@@ -46,14 +46,35 @@ func ArtifactCommand() cli.Command {
},
{
Name: "assert",
- Usage: "fail unless each artifact path or glob resolves",
+ Usage: "fail unless each artifact path or glob resolves (and fits the size budget)",
ArgsUsage: "PATH_OR_GLOB...",
+ Flags: []cli.Flag{
+ cli.StringFlag{Name: "max-size", Usage: "fail when any matched file exceeds this size (e.g. 5MB, 100KiB)"},
+ cli.StringFlag{Name: "min-size", Usage: "fail when any matched file is smaller than this size"},
+ },
Action: func(c *cli.Context) error {
patterns, err := argsOrErr(c, "artifact path or glob")
if err != nil {
return err
}
- return services.AssertArtifacts(patterns)
+ if err := services.AssertArtifacts(patterns); err != nil {
+ return err
+ }
+ if c.String("max-size") == "" && c.String("min-size") == "" {
+ return nil
+ }
+ var min, max int64
+ if v := c.String("max-size"); v != "" {
+ if max, err = services.ParseSize(v); err != nil {
+ return cli.NewExitError(err.Error(), 1)
+ }
+ }
+ if v := c.String("min-size"); v != "" {
+ if min, err = services.ParseSize(v); err != nil {
+ return cli.NewExitError(err.Error(), 1)
+ }
+ }
+ return services.AssertArtifactSizes(patterns, min, max)
},
},
},
diff --git a/actions/assert.go b/actions/assert.go
index c7ac61e..927fe74 100644
--- a/actions/assert.go
+++ b/actions/assert.go
@@ -1,6 +1,7 @@
package actions
import (
+ "fmt"
"os"
"strconv"
"strings"
@@ -177,6 +178,23 @@ func AssertCommand() cli.Command {
return nil
},
},
+ {
+ Name: "versions-match",
+ Usage: "assert all files declare the same version (monorepo release guard)",
+ ArgsUsage: "FILE FILE...",
+ Action: func(c *cli.Context) error {
+ files := c.Args()
+ if len(files) < 2 {
+ return cli.NewExitError("at least two files required", 1)
+ }
+ version, err := services.AssertVersionsMatch(files)
+ if err != nil {
+ return cli.NewExitError(err.Error(), 1)
+ }
+ fmt.Println(version)
+ return nil
+ },
+ },
{
Name: "path",
Usage: "assert one or more paths exist (file or directory)",
diff --git a/actions/http.go b/actions/http.go
index 7f75b7c..c6595e4 100644
--- a/actions/http.go
+++ b/actions/http.go
@@ -55,6 +55,9 @@ func httpMethodCommand(method string) cli.Command {
cli.BoolFlag{Name: "backoff", Usage: "exponential backoff between retries"},
cli.BoolFlag{Name: "insecure, k", Usage: "skip TLS certificate verification"},
cli.BoolFlag{Name: "verbose, v", Usage: "print response status and headers to stderr"},
+ cli.BoolFlag{Name: "paginate", Usage: "follow Link rel=\"next\" headers and merge all pages (GET only)"},
+ cli.StringFlag{Name: "items", Usage: "jq-style path to the items array inside each page (default: body is the array)"},
+ cli.IntFlag{Name: "max-pages", Value: 50, Usage: "pagination safety cap"},
},
Action: func(c *cli.Context) error {
urlStr, err := firstArgOrErr(c, "URL")
@@ -98,7 +101,7 @@ func httpMethodCommand(method string) cli.Command {
return cli.NewExitError(err.Error(), 1)
}
- res, err := services.ExecuteHTTPRequest(services.HTTPRequestOptions{
+ opts := services.HTTPRequestOptions{
Method: method,
URL: urlStr,
Headers: headers,
@@ -109,24 +112,44 @@ func httpMethodCommand(method string) cli.Command {
RetryDelay: retryDelay,
Backoff: c.Bool("backoff"),
InsecureTLS: c.Bool("insecure"),
- })
- if err != nil {
- return cli.NewExitError(err.Error(), 1)
- }
- if c.Bool("verbose") {
- printHTTPVerbose(res)
}
- out := res.Body
+ var out []byte
+ if c.Bool("paginate") {
+ if method != "GET" {
+ return cli.NewExitError("--paginate only works with GET", 1)
+ }
+ items, pages, err := services.ExecuteHTTPPaginated(opts, c.String("items"), c.Int("max-pages"))
+ if err != nil {
+ return cli.NewExitError(err.Error(), 1)
+ }
+ if c.Bool("verbose") {
+ fmt.Fprintf(os.Stderr, "fetched %d item(s) across %d page(s)\n", len(items), pages)
+ }
+ out, err = json.Marshal(items)
+ if err != nil {
+ return cli.NewExitError(err.Error(), 1)
+ }
+ out = append(out, '\n')
+ } else {
+ res, err := services.ExecuteHTTPRequest(opts)
+ if err != nil {
+ return cli.NewExitError(err.Error(), 1)
+ }
+ if c.Bool("verbose") {
+ printHTTPVerbose(res)
+ }
+ out = res.Body
+ }
if path := c.String("jq"); path != "" {
- val, err := services.ExtractHTTPJSON(res.Body, path)
+ val, err := services.ExtractHTTPJSON(out, path)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
out = []byte(formatHTTPResult(val, c.Bool("raw")))
}
if output := c.String("output"); output != "" {
- return os.WriteFile(output, out, 0644)
+ return os.WriteFile(output, out, 0o644)
}
if outputKey := c.String("to-github-output"); outputKey != "" {
return services.WriteToGitHubOutputValue(outputKey, strings.TrimRight(string(out), "\n"))
diff --git a/actions/json.go b/actions/json.go
index 8f0d82d..868ca58 100644
--- a/actions/json.go
+++ b/actions/json.go
@@ -38,6 +38,62 @@ func dataCommand(name, usage string, defaultFormat services.DataFormat) cli.Comm
dataConvertCmd(defaultFormat),
dataPrettyCmd(defaultFormat),
dataTableCmd(defaultFormat),
+ dataDiffCmd(defaultFormat),
+ },
+ }
+}
+
+func dataDiffCmd(def services.DataFormat) cli.Command {
+ return cli.Command{
+ Name: "diff",
+ Usage: "semantic diff of two files (key order and formatting ignored)",
+ ArgsUsage: "FILE1 FILE2",
+ Flags: []cli.Flag{
+ cli.StringFlag{Name: "output, o", Value: "text", Usage: "output format: text, json, markdown"},
+ cli.StringSliceFlag{Name: "ignore", Usage: "path prefix to exclude (repeatable, e.g. .metadata.generation)"},
+ cli.BoolFlag{Name: "exit-code", Usage: "exit 1 when the files differ (like git diff --exit-code)"},
+ cli.BoolFlag{Name: "to-summary", Usage: "append markdown output to GITHUB_STEP_SUMMARY"},
+ },
+ Action: func(c *cli.Context) error {
+ args := c.Args()
+ if len(args) < 2 {
+ return cli.NewExitError("two files required", 1)
+ }
+ docA, _, err := loadFileWithDefault(args[0], def)
+ if err != nil {
+ return err
+ }
+ docB, _, err := loadFileWithDefault(args[1], def)
+ if err != nil {
+ return err
+ }
+ entries := services.StructDiff(docA, docB, c.StringSlice("ignore"))
+
+ var out string
+ switch c.String("output") {
+ case "json":
+ out, err = services.FormatDiffJSON(entries)
+ if err != nil {
+ return err
+ }
+ case "markdown", "md":
+ out = services.FormatDiffMarkdown(entries)
+ default:
+ out = services.FormatDiffText(entries)
+ }
+
+ if c.Bool("to-summary") {
+ md := services.FormatDiffMarkdown(entries)
+ if err := services.AppendToGitHubSummary(md); err != nil {
+ return err
+ }
+ }
+ fmt.Print(out)
+
+ if c.Bool("exit-code") && len(entries) > 0 {
+ return cli.NewExitError("", 1)
+ }
+ return nil
},
}
}
@@ -51,18 +107,30 @@ func dataGetCmd(def services.DataFormat) cli.Command {
cli.StringFlag{Name: "path, p", Usage: "jq-style path expression (e.g. .image.tag)"},
cli.BoolFlag{Name: "raw, r", Usage: "if the result is a string, print it raw (no JSON quotes)"},
cli.StringFlag{Name: "from", Usage: "input format override (default: detect from extension)"},
+ docSelectorFlag(),
outputKeyFlag(),
},
Action: func(c *cli.Context) error {
- doc, _, err := readDoc(c, def)
- if err != nil {
- return err
- }
path := c.String("path")
if path == "" {
return cli.NewExitError("--path required", 1)
}
- val, err := services.JSONGet(doc, path)
+ var val interface{}
+ var err error
+ if sel := c.String("doc"); sel != "" {
+ data, readErr := readAllInput(c)
+ if readErr != nil {
+ return readErr
+ }
+ val, err = services.MultiDocGet(data, sel, path)
+ } else {
+ var doc interface{}
+ doc, _, err = readDoc(c, def)
+ if err != nil {
+ return err
+ }
+ val, err = services.JSONGet(doc, path)
+ }
if err != nil {
return err
}
@@ -84,6 +152,7 @@ func dataSetCmd(def services.DataFormat) cli.Command {
cli.BoolFlag{Name: "in-place, i", Usage: "write back to the file (default: stdout)"},
cli.BoolFlag{Name: "pretty", Usage: "pretty-print output"},
cli.BoolFlag{Name: "preserve, P", Usage: "surgical edit: keep comments/formatting, change only the target (yaml, json)"},
+ docSelectorFlag(),
},
Action: func(c *cli.Context) error {
file, err := firstArgOrErr(c, "FILE")
@@ -103,6 +172,12 @@ func dataSetCmd(def services.DataFormat) cli.Command {
newVal = c.String("value")
}
+ if sel := c.String("doc"); sel != "" {
+ return writeRawEdit(file, c.Bool("in-place"), func(data []byte) ([]byte, error) {
+ return services.MultiDocSet(data, sel, path, newVal, c.Bool("preserve"))
+ })
+ }
+
if c.Bool("preserve") {
return writePreserved(c, file, def, c.Bool("in-place"), func(data []byte, format services.DataFormat) ([]byte, error) {
return services.SetPreserving(data, format, path, newVal)
@@ -132,6 +207,7 @@ func dataDelCmd(def services.DataFormat) cli.Command {
cli.BoolFlag{Name: "in-place, i"},
cli.BoolFlag{Name: "pretty"},
cli.BoolFlag{Name: "preserve, P", Usage: "surgical edit: keep comments/formatting, remove only the target (yaml, json)"},
+ docSelectorFlag(),
},
Action: func(c *cli.Context) error {
file, err := firstArgOrErr(c, "FILE")
@@ -143,6 +219,12 @@ func dataDelCmd(def services.DataFormat) cli.Command {
return cli.NewExitError("--path required", 1)
}
+ if sel := c.String("doc"); sel != "" {
+ return writeRawEdit(file, c.Bool("in-place"), func(data []byte) ([]byte, error) {
+ return services.MultiDocDel(data, sel, path, c.Bool("preserve"))
+ })
+ }
+
if c.Bool("preserve") {
return writePreserved(c, file, def, c.Bool("in-place"), func(data []byte, format services.DataFormat) ([]byte, error) {
return services.DelPreserving(data, format, path)
@@ -196,7 +278,7 @@ func dataMergeCmd(def services.DataFormat) cli.Command {
return err
}
if out != "" {
- return os.WriteFile(out, data, 0644)
+ return os.WriteFile(out, data, 0o644)
}
fmt.Print(string(data))
if format == services.FormatJSON {
@@ -364,7 +446,7 @@ func writeResult(c *cli.Context, srcPath string, doc interface{}, def services.D
return err
}
if inPlace {
- return os.WriteFile(srcPath, data, 0644)
+ return os.WriteFile(srcPath, data, 0o644)
}
fmt.Print(string(data))
if format == services.FormatJSON {
@@ -373,6 +455,32 @@ func writeResult(c *cli.Context, srcPath string, doc interface{}, def services.D
return nil
}
+// docSelectorFlag returns the --doc flag for multi-document YAML streams.
+func docSelectorFlag() cli.StringFlag {
+ return cli.StringFlag{
+ Name: "doc, d",
+ Usage: "select a document in a multi-doc YAML stream: index (0) or field match (kind=Deployment,metadata.name=api)",
+ }
+}
+
+// writeRawEdit reads a file's raw bytes, applies edit, and writes the result
+// back in place (atomically) or to stdout.
+func writeRawEdit(srcPath string, inPlace bool, edit func([]byte) ([]byte, error)) error {
+ data, err := os.ReadFile(srcPath)
+ if err != nil {
+ return err
+ }
+ out, err := edit(data)
+ if err != nil {
+ return err
+ }
+ if inPlace {
+ return atomicWriteFile(srcPath, out)
+ }
+ fmt.Print(string(out))
+ return nil
+}
+
// writePreserved reads the source file's raw bytes, applies a formatting-
// preserving edit, and either writes back in place or prints to stdout. Unlike
// writeResult it never round-trips through Decode/Encode, so the file is changed
@@ -402,7 +510,7 @@ func writePreserved(c *cli.Context, srcPath string, def services.DataFormat, inP
// kill mid-write leaves the original file fully intact rather than truncated.
// The original file's permission bits are preserved.
func atomicWriteFile(path string, data []byte) error {
- mode := os.FileMode(0644)
+ mode := os.FileMode(0o644)
if info, err := os.Stat(path); err == nil {
mode = info.Mode().Perm()
}
diff --git a/actions/lock.go b/actions/lock.go
new file mode 100644
index 0000000..d8d2566
--- /dev/null
+++ b/actions/lock.go
@@ -0,0 +1,80 @@
+package actions
+
+import (
+ "time"
+
+ "github.com/AxeForging/pipekit/services"
+
+ "github.com/urfave/cli"
+)
+
+// LockCommand returns the file-based mutex command group.
+func LockCommand() cli.Command {
+ lockFlags := []cli.Flag{
+ cli.StringFlag{Name: "file, f", Usage: "lock file path (required)"},
+ cli.DurationFlag{Name: "timeout, t", Value: 60 * time.Second, Usage: "how long to wait for the lock"},
+ cli.DurationFlag{Name: "stale", Usage: "treat a lock older than this as abandoned and take it over (0 = never)"},
+ }
+ return cli.Command{
+ Name: "lock",
+ Usage: "serialize pipeline steps with a file-based mutex",
+ Subcommands: []cli.Command{
+ {
+ Name: "acquire",
+ Usage: "take the lock, waiting up to --timeout",
+ Flags: lockFlags,
+ Action: func(c *cli.Context) error {
+ path, err := lockPath(c)
+ if err != nil {
+ return err
+ }
+ return services.AcquireLock(path, c.Duration("timeout"), c.Duration("stale"))
+ },
+ },
+ {
+ Name: "release",
+ Usage: "release the lock (safe to call when not held)",
+ Flags: lockFlags[:1],
+ Action: func(c *cli.Context) error {
+ path, err := lockPath(c)
+ if err != nil {
+ return err
+ }
+ return services.ReleaseLock(path)
+ },
+ },
+ {
+ Name: "run",
+ Usage: "acquire the lock, run a command, release afterwards",
+ ArgsUsage: "-- COMMAND [ARGS...]",
+ Flags: lockFlags,
+ Action: func(c *cli.Context) error {
+ path, err := lockPath(c)
+ if err != nil {
+ return err
+ }
+ args := c.Args()
+ if len(args) == 0 {
+ return cli.NewExitError("command required after --", 1)
+ }
+ code, err := services.RunWithLock(path, c.Duration("timeout"), c.Duration("stale"), args)
+ if err != nil {
+ return err
+ }
+ if code != 0 {
+ return cli.NewExitError("", code)
+ }
+ return nil
+ },
+ },
+ },
+ }
+}
+
+func lockPath(c *cli.Context) (string, error) {
+ path := c.String("file")
+ if path == "" {
+ return "", cli.NewExitError("--file required", 1)
+ }
+ return path, nil
+}
diff --git a/actions/render.go b/actions/render.go
index fe323fe..cfccff6 100644
--- a/actions/render.go
+++ b/actions/render.go
@@ -20,6 +20,8 @@ func RenderCommand() cli.Command {
cli.StringSliceFlag{Name: "values, v", Usage: "JSON/YAML/TOML values file (repeatable; later wins)"},
cli.StringSliceFlag{Name: "set, s", Usage: "key=value override (repeatable, dotted keys ok)"},
cli.StringFlag{Name: "output, o", Usage: "write output to this file (default: stdout)"},
+ cli.BoolFlag{Name: "envsubst", Usage: "envsubst mode: substitute $VAR / ${VAR} / ${VAR:-default} from the environment instead of Go templating"},
+ cli.BoolFlag{Name: "strict", Usage: "with --envsubst, fail when a referenced variable is unset and has no default"},
},
Action: func(c *cli.Context) error {
tmplPath := c.String("template")
@@ -30,6 +32,22 @@ func RenderCommand() cli.Command {
return cli.NewExitError("template file required (positional or --template)", 1)
}
+ if c.Bool("envsubst") {
+ data, err := os.ReadFile(tmplPath)
+ if err != nil {
+ return err
+ }
+ out, err := services.Envsubst(string(data), services.EnvsubstOptions{Strict: c.Bool("strict")})
+ if err != nil {
+ return err
+ }
+ if outPath := c.String("output"); outPath != "" {
+ return os.WriteFile(outPath, []byte(out), 0o644)
+ }
+ fmt.Print(out)
+ return nil
+ }
+
values := make(map[string]interface{})
for _, vp := range c.StringSlice("values") {
v, err := services.LoadValues(vp)
@@ -47,7 +65,7 @@ func RenderCommand() cli.Command {
return err
}
if outPath := c.String("output"); outPath != "" {
- return os.WriteFile(outPath, []byte(out), 0644)
+ return os.WriteFile(outPath, []byte(out), 0o644)
}
fmt.Print(out)
return nil
diff --git a/actions/report.go b/actions/report.go
new file mode 100644
index 0000000..9517d1b
--- /dev/null
+++ b/actions/report.go
@@ -0,0 +1,123 @@
+package actions
+
+import (
+ "fmt"
+ "io"
+ "strconv"
+
+ "github.com/AxeForging/pipekit/services"
+
+ "github.com/urfave/cli"
+)
+
+// ReportCommand returns the test/coverage report command group.
+func ReportCommand() cli.Command {
+ return cli.Command{
+ Name: "report",
+ Usage: "parse test and coverage reports into summaries and CI gates",
+ Subcommands: []cli.Command{
+ {
+ Name: "junit",
+ Usage: "summarize a JUnit XML report (markdown, counts, failure gate)",
+ ArgsUsage: "[FILE]",
+ Flags: []cli.Flag{
+ cli.StringFlag{Name: "output, o", Value: "markdown", Usage: "output format: markdown, json"},
+ cli.IntFlag{Name: "slowest", Value: 5, Usage: "include the N slowest tests in markdown (0 to disable)"},
+ cli.BoolFlag{Name: "fail-on-failures", Usage: "exit 1 when the report contains failures or errors"},
+ cli.BoolFlag{Name: "to-summary", Usage: "append markdown to GITHUB_STEP_SUMMARY"},
+ cli.StringFlag{Name: "to-github-output", Usage: "write total/passed/failures/errors/skipped to $GITHUB_OUTPUT with this key prefix"},
+ },
+ Action: func(c *cli.Context) error {
+ data, err := readReportInput(c)
+ if err != nil {
+ return err
+ }
+ report, err := services.ParseJUnit(data)
+ if err != nil {
+ return err
+ }
+
+ var out string
+ if c.String("output") == "json" {
+ out, err = services.FormatJUnitJSON(report)
+ if err != nil {
+ return err
+ }
+ } else {
+ out = services.FormatJUnitMarkdown(report, c.Int("slowest"))
+ }
+
+ if c.Bool("to-summary") {
+ md := services.FormatJUnitMarkdown(report, c.Int("slowest"))
+ if err := services.AppendToGitHubSummary(md); err != nil {
+ return err
+ }
+ }
+ if prefix := c.String("to-github-output"); prefix != "" {
+ counts := map[string]int{
+ "total": report.Total, "passed": report.Passed,
+ "failures": report.Failures, "errors": report.Errors,
+ "skipped": report.Skipped,
+ }
+ for key, v := range counts {
+ if err := services.WriteToGitHubOutputValue(prefix+"_"+key, strconv.Itoa(v)); err != nil {
+ return err
+ }
+ }
+ }
+ fmt.Print(out)
+
+ if c.Bool("fail-on-failures") && report.Failures+report.Errors > 0 {
+ return cli.NewExitError(fmt.Sprintf("%d test(s) failed", report.Failures+report.Errors), 1)
+ }
+ return nil
+ },
+ },
+ {
+ Name: "coverage",
+ Usage: "extract total coverage from lcov/cobertura and gate on a threshold",
+ ArgsUsage: "[FILE]",
+ Flags: []cli.Flag{
+ cli.StringFlag{Name: "format, f", Value: "auto", Usage: "report format: auto, lcov, cobertura"},
+ cli.Float64Flag{Name: "min", Value: -1, Usage: "minimum coverage percentage; exit 1 below it"},
+ cli.BoolFlag{Name: "to-summary", Usage: "append markdown to GITHUB_STEP_SUMMARY"},
+ outputKeyFlag(),
+ },
+ Action: func(c *cli.Context) error {
+ data, err := readReportInput(c)
+ if err != nil {
+ return err
+ }
+ pct, err := services.ParseCoverage(data, c.String("format"))
+ if err != nil {
+ return err
+ }
+ min := c.Float64("min")
+
+ if c.Bool("to-summary") {
+ if err := services.AppendToGitHubSummary(services.FormatCoverageMarkdown(pct, min)); err != nil {
+ return err
+ }
+ }
+ if err := emitString(c, fmt.Sprintf("%.1f", pct)); err != nil {
+ return err
+ }
+
+ if min >= 0 && pct < min {
+ return cli.NewExitError(fmt.Sprintf("coverage %.1f%% below threshold %.1f%%", pct, min), 1)
+ }
+ return nil
+ },
+ },
+ },
+ }
+}
+
+func readReportInput(c *cli.Context) ([]byte, error) {
+ r, err := readerFromArgOrStdin(c)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = r.Close() }()
+ return io.ReadAll(r)
+}
diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md
index 93ab9ad..e1bae8f 100644
--- a/docs/COMMANDS.md
+++ b/docs/COMMANDS.md
@@ -32,6 +32,9 @@ Full reference for every pipekit command and flag. For end-to-end pipeline recip
- [`time`](#time) — timestamps, formatting, arithmetic
- [`port` · `uuid` · `random`](#misc) — small generators
- [`doctor`](#doctor) — environment diagnostics
+- [`report`](#report) — JUnit / coverage parsing and gates
+- [`annotate`](#annotate) — CI code annotations
+- [`lock`](#lock) — file-based mutex for pipeline steps
- [Exit codes](#exit-codes)
---
@@ -321,6 +324,9 @@ pipekit assert path /etc/myapp/config.yaml /var/lib/myapp
# Directory exists and contains entries
pipekit assert dir-not-empty ./build/output
+
+# All files declare the same version (monorepo release guard)
+pipekit assert versions-match package.json helm/Chart.yaml
```
Comparison operators: `gt`, `lt`, `eq`, `gte`, `lte` (and `>`, `<`, `==`, `>=`, `<=`).
@@ -626,13 +632,17 @@ Validate and describe CI artifacts before upload or release.
# Fail early if expected build outputs are missing
pipekit artifact assert "dist/pipekit-linux-*" "dist/checksums.txt"
+# Enforce a size budget (bundle bloat gate)
+pipekit artifact assert "dist/bundle.js" --max-size 5MB
+pipekit artifact assert "dist/pipekit-*" --min-size 1MB --max-size 50MB
+
# Generate a JSON manifest with path, size, and sha256
pipekit artifact manifest "dist/pipekit-*" --pretty --output dist/artifacts.json
```
| Subcommand | Description |
|---|---|
-| `artifact assert PATH_OR_GLOB...` | Fail unless each path/glob resolves to at least one file |
+| `artifact assert PATH_OR_GLOB...` | Fail unless each path/glob resolves to at least one file; `--max-size` / `--min-size` add a size budget (`5MB`, `100KiB`, `512`) |
| `artifact manifest PATH_OR_GLOB...` | Emit JSON artifact metadata |
@@ -1055,6 +1065,60 @@ Ideal for hand-maintained files like Helm `values.yaml`.
+
+--doc — address one document in a multi-doc YAML stream
+
+```sh
+# Select by field match (k8s manifests: kind + name)
+pipekit yaml get manifests.yaml --doc kind=Service --path '.spec.ports[0].port'
+pipekit yaml set manifests.yaml --doc kind=Deployment,metadata.name=api \
+ --path '.spec.replicas' --json-value 5 --preserve --in-place
+
+# Or by zero-based document index
+pipekit yaml del manifests.yaml --doc 2 --path '.spec.legacy' --in-place
+```
+
+`get`, `set`, and `del` accept `--doc ` for `---`-separated streams.
+The selector is either a document index or comma-separated `dotpath=value`
+matches, which must resolve to exactly one document (ambiguity is an error, so
+`kind=Deployment` alone fails if the stream has two Deployments). Edits touch
+**only the selected document's bytes** — sibling documents and separators stay
+byte-identical, and `--preserve` keeps comments/formatting inside the edited
+document too. This is the yq pain point pipekit removes: no `select(di == …)`
+gymnastics, no reformatted siblings.
+
+
+
+
+json diff / yaml diff — semantic diff (order and formatting ignored)
+
+```sh
+# What actually changed between two rendered Helm value sets?
+pipekit yaml diff old-values.yaml new-values.yaml
+# ~ .image.tag: "v1.2.2" -> "v1.2.3"
+# + .autoscaling.enabled: true
+# - .legacyFlag: false
+
+# Gate: exit 1 when files differ (like git diff --exit-code)
+pipekit json diff expected.json actual.json --exit-code
+
+# Markdown table into the step summary / a PR comment
+pipekit yaml diff base.yaml overlay.yaml --output markdown --to-summary
+
+# Machine-readable
+pipekit yaml diff a.yaml b.yaml --output json
+
+# Skip noisy paths (repeatable prefix match)
+pipekit yaml diff live.yaml desired.yaml --ignore .metadata.generation --ignore .status
+```
+
+Compares the *decoded* documents, so key order, quoting, indentation, and
+comments never produce a diff — only real value changes do. Cross-format works
+too (`pipekit json diff config.json config.yaml`). Output formats: `text`
+(default, `+`/`-`/`~` lines), `json`, `markdown`.
+
+
+
json merge — deep-merge files (helm-style overrides)
@@ -1103,6 +1167,10 @@ pipekit render deployment.yaml.tpl \
# Inline template through stdin
echo 'Hello {{ .Values.name | default "world" }}' \
| pipekit transform template
+
+# envsubst mode — drop-in replacement for the gettext binary
+pipekit render config.tmpl --envsubst --output config.yaml # $VAR, ${VAR}, ${VAR:-default}, $$ escapes
+pipekit render config.tmpl --envsubst --strict # fail listing unset vars without defaults
```
**Available functions:** `default`, `env`, `envOr`, `b64enc`, `b64dec`, `sha256sum`, `sha1sum`, `md5sum`, `regexReplace`, `regexMatch`, `replace`, `lower`, `upper`, `trim`, `trimPrefix`, `trimSuffix`, `contains`, `hasPrefix`, `hasSuffix`, `split`, `join`, `quote`, `squote`, `indent`, `nindent`, `ternary`, `toJson`, `toYaml`, `fromJson`, `fromYaml`, `now`, `date`, `list`, `dict`.
@@ -1180,6 +1248,15 @@ pipekit http post https://uploads.example.com/artifacts \
--file artifact=dist/app.tar.zst \
--expect-status 200
+# Follow Link rel="next" pagination and merge every page (GitHub-style APIs)
+pipekit http get "https://api.github.com/repos/org/repo/tags?per_page=100" \
+ --header "Authorization: Bearer $GH_TOKEN" \
+ --paginate \
+ --jq '.[].name'
+
+# Items nested inside an object? Point --items at the array
+pipekit http get https://api.example.com/builds --paginate --items .data --max-pages 20
+
# Chain dependent requests with captures and interpolation
pipekit http chain flow.yaml --expect-status 200 --verbose
@@ -1352,6 +1429,107 @@ Detects: GitHub Actions, GitLab CI, Buildkite, CircleCI, Jenkins, plus a generic
---
+## report
+
+Parse test and coverage reports into markdown summaries and CI gates — no more python one-liners over JUnit XML.
+
+
+Examples
+
+```sh
+# Markdown summary (counts, failed tests, slowest tests) + failure gate
+pipekit report junit test-results.xml --to-summary --fail-on-failures
+
+# Counts to GITHUB_OUTPUT (tests_total, tests_passed, tests_failures, …)
+pipekit report junit test-results.xml --to-github-output tests
+
+# JSON for further processing
+pipekit report junit test-results.xml --output json | pipekit json get --path .failures
+
+# Coverage percentage from lcov or cobertura (format auto-detected)
+pipekit report coverage coverage/lcov.info
+# 84.5
+
+# Threshold gate + step summary line
+pipekit report coverage coverage.xml --min 80 --to-summary
+```
+
+| Subcommand | Description |
+|---|---|
+| `report junit [FILE]` | Parse JUnit XML (nested suites supported); `--slowest N`, `--fail-on-failures`, `--to-summary`, `--to-github-output PREFIX`, `--output markdown\|json` |
+| `report coverage [FILE]` | Total line coverage from `lcov` or `cobertura` (auto-detect); `--min PCT` exits 1 below threshold; `--to-summary`, `--to-github-output` |
+
+
+
+---
+
+## annotate
+
+Emit code annotations that GitHub renders inline on the PR (workflow commands); on other platforms they fall back to grep-able `file:line: level: message` lines. Platform is auto-detected (`--platform` to force).
+
+
+Examples
+
+```sh
+# One-off annotation
+pipekit annotate error --file src/app.go --line 42 --title "nil deref" "response can be nil here"
+# ::error file=src/app.go,line=42,title=nil deref::response can be nil here
+
+pipekit annotate warning "deploy skipped: no changes detected"
+
+# Convert any linter's JSON findings into inline annotations
+eslint . --format json | jq '[.[] | .messages[] as $m | {file: .filePath, line: $m.line, message: $m.message, severity: (if $m.severity == 2 then "error" else "warning" end)}]' \
+ | pipekit annotate from-json --fail-on-errors
+
+# Field names configurable — no jq needed when the shape is flat
+shellcheck -f json1 script.sh | pipekit json get --path .comments \
+ | pipekit annotate from-json --line-key line --severity-key level --fail-on-errors
+```
+
+Severities normalize onto GitHub's three levels: `error`/`fatal`/`critical` → error, `warning`/`minor` → warning, everything else → notice.
+
+| Subcommand | Description |
+|---|---|
+| `annotate error\|warning\|notice MESSAGE` | Single annotation; `--file`, `--line`, `--end-line`, `--col`, `--title` |
+| `annotate from-json [FILE]` | JSON findings array → annotations; `--file-key`, `--line-key`, `--message-key`, `--severity-key`, `--title-key`, `--fail-on-errors` |
+
+
+
+---
+
+## lock
+
+Serialize pipeline steps across concurrent jobs sharing a runner or workspace — a portable `flock` replacement with timeout and stale-lock takeover.
+
+
+Examples
+
+```sh
+# Acquire → work → release
+pipekit lock acquire --file /tmp/deploy.lock --timeout 5m
+helm upgrade --install myapp ./chart
+pipekit lock release --file /tmp/deploy.lock
+
+# Or wrap the command (lock is released even if it fails)
+pipekit lock run --file /tmp/deploy.lock --timeout 5m --stale 30m -- \
+ terraform apply -auto-approve
+```
+
+The lock file records holder pid + timestamp for debugging. `--stale 30m`
+treats a lock older than 30 minutes as abandoned (crashed job) and takes it
+over; without `--stale` a leftover lock is never stolen. `lock release` on a
+missing file succeeds, so it's safe in `always()` cleanup steps.
+
+| Subcommand | Description |
+|---|---|
+| `lock acquire --file PATH` | Take the lock, waiting up to `--timeout` (default 60s) |
+| `lock release --file PATH` | Remove the lock (idempotent) |
+| `lock run --file PATH -- CMD…` | Acquire, run, always release; propagates the command's exit code |
+
+
+
+---
+
## Exit codes
| Code | Meaning |
diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md
index 18cd232..0063862 100644
--- a/docs/EXAMPLES.md
+++ b/docs/EXAMPLES.md
@@ -16,6 +16,8 @@ For per-command flag reference see **[COMMANDS.md](COMMANDS.md)**.
- [Step summaries that look good](#step-summaries)
- [GitLab CI](#gitlab-ci)
- [Jenkins](#jenkins)
+- [v0.2 patterns: json, render, exec](#v02-patterns-json-render-exec)
+- [v0.3 patterns: reports, annotations, diffs, locks](#v03-patterns-reports-annotations-diffs-locks)
- [Combining commands](#combining-commands)
---
@@ -575,6 +577,125 @@ jobs:
---
+## v0.3 patterns: reports, annotations, diffs, locks
+
+
+Test summary + coverage gate without python
+
+```yaml
+- name: Test
+ run: go test ./... 2>&1 | go-junit-report > junit.xml
+
+- name: Report
+ if: always()
+ run: |
+ pipekit report junit junit.xml --to-summary --fail-on-failures
+ pipekit report coverage coverage/lcov.info --min 80 --to-summary
+```
+
+Replaces: a python script parsing JUnit XML, a `bc`-based coverage
+percentage calculation, and hand-rolled `$GITHUB_STEP_SUMMARY` heredocs.
+
+
+
+
+What changed in the rendered manifests? (semantic diff → PR comment)
+
+```sh
+helm template myapp ./chart -f values-prod.yaml > /tmp/new.yaml
+git show origin/main:rendered/prod.yaml > /tmp/old.yaml
+
+pipekit yaml diff /tmp/old.yaml /tmp/new.yaml \
+ --ignore .metadata.labels.chart \
+ --output markdown \
+| gh pr comment "$PR" --body-file -
+```
+
+Key order, indentation, and comments never show up as changes — only real
+value drift does.
+
+
+
+
+Bump one document inside a k8s manifest stream (yq can't do this cleanly)
+
+```sh
+pipekit yaml set manifests.yaml \
+ --doc kind=Deployment,metadata.name=api \
+ --path '.spec.template.spec.containers[0].image' \
+ --value "ghcr.io/org/api:$TAG" \
+ --preserve --in-place
+```
+
+Only the selected Deployment changes; the Service and every comment in the
+file stay byte-identical.
+
+
+
+
+Linter findings as inline PR annotations
+
+```sh
+mylinter --json \
+ | pipekit annotate from-json \
+ --file-key path --line-key lineNumber --message-key text --severity-key level \
+ --fail-on-errors
+```
+
+Works for any tool that emits a JSON findings array — map field names with
+the `--*-key` flags instead of reshaping with jq.
+
+
+
+
+Serialize deploys on a shared runner
+
+```sh
+pipekit lock run --file /tmp/prod-deploy.lock --timeout 10m --stale 30m -- \
+ helm upgrade --install myapp ./chart
+```
+
+
+
+
+Fetch every page of a paginated API
+
+```sh
+# All tags, not just the first 30
+pipekit http get "https://api.github.com/repos/org/repo/tags?per_page=100" \
+ --header "Authorization: Bearer $GH_TOKEN" \
+ --paginate --jq '.[0].name'
+```
+
+
+
+
+Migrate envsubst templates without rewriting them
+
+```sh
+IMAGE=app:v2 pipekit render k8s/deploy.tmpl.yaml --envsubst --strict --output deploy.yaml
+```
+
+`--strict` fails listing every unset variable instead of silently rendering
+empty strings.
+
+
+
+
+Monorepo release guards
+
+```sh
+# package.json, Chart.yaml and pyproject.toml must agree before tagging
+pipekit assert versions-match package.json helm/Chart.yaml pyproject.toml
+
+# Keep the CLI binary under budget
+pipekit artifact assert "dist/mycli-*" --max-size 50MB
+```
+
+
+
+---
+
## Combining commands
The real value comes from chaining a few commands together. A few patterns:
diff --git a/integration/timesavers_test.go b/integration/timesavers_test.go
new file mode 100644
index 0000000..a9cb978
--- /dev/null
+++ b/integration/timesavers_test.go
@@ -0,0 +1,152 @@
+package integration
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func writeTempFile(t *testing.T, name, content string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), name)
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatalf("writing %s: %v", name, err)
+ }
+ return path
+}
+
+func TestE2E_YAMLDiff(t *testing.T) {
+ a := writeTempFile(t, "a.yaml", "image: app:v1\nreplicas: 2\n")
+ b := writeTempFile(t, "b.yaml", "replicas: 3\nimage: app:v1\n")
+
+ stdout, _, code := runPipekit(t, []string{"yaml", "diff", a, b, "--exit-code"}, "")
+ if code != 1 {
+ t.Errorf("exit = %d, want 1 with --exit-code and differences", code)
+ }
+ if !strings.Contains(stdout, "~ .replicas: 2 -> 3") {
+ t.Errorf("stdout = %q", stdout)
+ }
+
+ _, _, code = runPipekit(t, []string{"yaml", "diff", a, a, "--exit-code"}, "")
+ if code != 0 {
+ t.Errorf("identical files exit = %d, want 0", code)
+ }
+}
+
+func TestE2E_YAMLMultiDocSetPreserve(t *testing.T) {
+ manifest := "# lead comment\nkind: Deployment\nmetadata:\n name: api\nspec:\n replicas: 2 # sync with HPA\n---\nkind: Service\nmetadata:\n name: api\n"
+ path := writeTempFile(t, "m.yaml", manifest)
+
+ _, stderr, code := runPipekit(t, []string{
+ "yaml", "set", path, "--doc", "kind=Deployment", "--path", ".spec.replicas",
+ "--json-value", "5", "--preserve", "--in-place",
+ }, "")
+ if code != 0 {
+ t.Fatalf("exit = %d, stderr = %s", code, stderr)
+ }
+ data, _ := os.ReadFile(path)
+ got := string(data)
+ if !strings.Contains(got, "replicas: 5 # sync with HPA") {
+ t.Errorf("edit lost comment or value:\n%s", got)
+ }
+ if !strings.Contains(got, "# lead comment") || !strings.Contains(got, "kind: Service\nmetadata:\n name: api\n") {
+ t.Errorf("sibling content changed:\n%s", got)
+ }
+}
+
+func TestE2E_ReportJUnit(t *testing.T) {
+ junit := ``
+ path := writeTempFile(t, "junit.xml", junit)
+
+ stdout, _, code := runPipekit(t, []string{"report", "junit", path, "--fail-on-failures"}, "")
+ if code != 1 {
+ t.Errorf("exit = %d, want 1 with failures", code)
+ }
+ if !strings.Contains(stdout, "2 tests") || !strings.Contains(stdout, "`bad`") {
+ t.Errorf("stdout = %q", stdout)
+ }
+}
+
+func TestE2E_ReportCoverage(t *testing.T) {
+ lcov := "SF:a.go\nLF:10\nLH:9\nend_of_record\n"
+ path := writeTempFile(t, "cov.info", lcov)
+
+ stdout, _, code := runPipekit(t, []string{"report", "coverage", path, "--min", "80"}, "")
+ if code != 0 || strings.TrimSpace(stdout) != "90.0" {
+ t.Errorf("exit = %d stdout = %q, want 0 / 90.0", code, stdout)
+ }
+
+ _, _, code = runPipekit(t, []string{"report", "coverage", path, "--min", "95"}, "")
+ if code != 1 {
+ t.Errorf("exit = %d, want 1 below threshold", code)
+ }
+}
+
+func TestE2E_AnnotateFromJSON(t *testing.T) {
+ findings := `[{"file": "a.go", "line": 3, "severity": "error", "message": "bad thing"}]`
+ stdout, _, code := runPipekit(t,
+ []string{"annotate", "from-json", "--platform", "github-actions"}, findings)
+ if code != 0 {
+ t.Fatalf("exit = %d", code)
+ }
+ if !strings.Contains(stdout, "::error file=a.go,line=3::bad thing") {
+ t.Errorf("stdout = %q", stdout)
+ }
+}
+
+func TestE2E_RenderEnvsubst(t *testing.T) {
+ tmpl := writeTempFile(t, "config.tmpl", "image: ${IMAGE}\nregion: ${REGION:-eu-west-1}\n")
+ stdout, _, code := runPipekit(t, []string{"render", "--envsubst", tmpl}, "", "IMAGE=app:v9")
+ if code != 0 {
+ t.Fatalf("exit = %d", code)
+ }
+ if !strings.Contains(stdout, "image: app:v9") || !strings.Contains(stdout, "region: eu-west-1") {
+ t.Errorf("stdout = %q", stdout)
+ }
+
+ _, stderr, code := runPipekit(t, []string{"render", "--envsubst", "--strict", tmpl}, "")
+ if code == 0 || !strings.Contains(stderr, "IMAGE") {
+ t.Errorf("strict mode should fail mentioning IMAGE: exit=%d stderr=%q", code, stderr)
+ }
+}
+
+func TestE2E_LockRun(t *testing.T) {
+ lock := filepath.Join(t.TempDir(), "ci.lock")
+ _, _, code := runPipekit(t, []string{"lock", "run", "--file", lock, "--", "true"}, "")
+ if code != 0 {
+ t.Errorf("lock run true exit = %d", code)
+ }
+ if _, err := os.Stat(lock); !os.IsNotExist(err) {
+ t.Error("lock file should be released")
+ }
+}
+
+func TestE2E_AssertVersionsMatch(t *testing.T) {
+ pkg := writeTempFile(t, "package.json", `{"version": "2.0.0"}`)
+ chart := writeTempFile(t, "Chart.yaml", "version: 2.0.0\n")
+
+ stdout, _, code := runPipekit(t, []string{"assert", "versions-match", pkg, chart}, "")
+ if code != 0 || strings.TrimSpace(stdout) != "2.0.0" {
+ t.Errorf("exit = %d stdout = %q", code, stdout)
+ }
+
+ mismatched := writeTempFile(t, "VERSION", "3.0.0\n")
+ _, _, code = runPipekit(t, []string{"assert", "versions-match", pkg, mismatched}, "")
+ if code == 0 {
+ t.Error("mismatched versions should fail")
+ }
+}
+
+func TestE2E_ArtifactSizeBudget(t *testing.T) {
+ bin := writeTempFile(t, "app.bin", strings.Repeat("x", 2048))
+
+ _, _, code := runPipekit(t, []string{"artifact", "assert", bin, "--max-size", "4KB"}, "")
+ if code != 0 {
+ t.Errorf("within budget exit = %d", code)
+ }
+ _, stderr, code := runPipekit(t, []string{"artifact", "assert", bin, "--max-size", "1KB"}, "")
+ if code == 0 || !strings.Contains(stderr, "size budget") {
+ t.Errorf("over budget: exit=%d stderr=%q", code, stderr)
+ }
+}
diff --git a/main.go b/main.go
index 9748cb4..8a6b6c6 100644
--- a/main.go
+++ b/main.go
@@ -58,6 +58,9 @@ func main() {
actions.UUIDCommand(),
actions.RandomCommand(),
actions.DoctorCommand(),
+ actions.ReportCommand(),
+ actions.AnnotateCommand(),
+ actions.LockCommand(),
{
Name: "build-info",
Usage: "show build version information",
diff --git a/services/annotate_service.go b/services/annotate_service.go
new file mode 100644
index 0000000..471cefc
--- /dev/null
+++ b/services/annotate_service.go
@@ -0,0 +1,176 @@
+package services
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// Annotation is a single code annotation (linter finding, test failure, …).
+type Annotation struct {
+ Level string // error, warning, notice
+ File string
+ Line int
+ EndLine int
+ Col int
+ Title string
+ Message string
+}
+
+// AnnotationKeys maps generic finding-JSON field names onto Annotation
+// fields, so any linter's JSON output can be converted without jq gymnastics.
+type AnnotationKeys struct {
+ File string
+ Line string
+ Col string
+ Message string
+ Severity string
+ Title string
+}
+
+// DefaultAnnotationKeys returns the field names most linters use.
+func DefaultAnnotationKeys() AnnotationKeys {
+ return AnnotationKeys{
+ File: "file",
+ Line: "line",
+ Col: "col",
+ Message: "message",
+ Severity: "severity",
+ Title: "title",
+ }
+}
+
+// NormalizeAnnotationLevel maps common severity spellings onto the three
+// levels GitHub understands.
+func NormalizeAnnotationLevel(level string) string {
+ switch strings.ToLower(level) {
+ case "error", "err", "fatal", "critical", "blocker", "major":
+ return "error"
+ case "warning", "warn", "minor":
+ return "warning"
+ default:
+ return "notice"
+ }
+}
+
+// FormatAnnotation renders an annotation for the given CI platform.
+// On github-actions it emits a workflow command that GitHub turns into an
+// inline PR annotation; everywhere else it falls back to a grep-able
+// file:line: level: message line.
+func FormatAnnotation(a Annotation, platform string) string {
+ level := NormalizeAnnotationLevel(a.Level)
+ if platform == "github-actions" {
+ var props []string
+ if a.File != "" {
+ props = append(props, "file="+escapeGitHubProperty(a.File))
+ }
+ if a.Line > 0 {
+ props = append(props, fmt.Sprintf("line=%d", a.Line))
+ }
+ if a.EndLine > 0 {
+ props = append(props, fmt.Sprintf("endLine=%d", a.EndLine))
+ }
+ if a.Col > 0 {
+ props = append(props, fmt.Sprintf("col=%d", a.Col))
+ }
+ if a.Title != "" {
+ props = append(props, "title="+escapeGitHubProperty(a.Title))
+ }
+ propStr := ""
+ if len(props) > 0 {
+ propStr = " " + strings.Join(props, ",")
+ }
+ return fmt.Sprintf("::%s%s::%s", level, propStr, escapeGitHubData(a.Message))
+ }
+
+ loc := a.File
+ if a.Line > 0 {
+ loc = fmt.Sprintf("%s:%d", loc, a.Line)
+ if a.Col > 0 {
+ loc = fmt.Sprintf("%s:%d", loc, a.Col)
+ }
+ }
+ msg := a.Message
+ if a.Title != "" {
+ msg = a.Title + ": " + msg
+ }
+ if loc != "" {
+ return fmt.Sprintf("%s: %s: %s", loc, level, msg)
+ }
+ return fmt.Sprintf("%s: %s", level, msg)
+}
+
+// escapeGitHubData escapes a workflow-command message payload.
+func escapeGitHubData(s string) string {
+ s = strings.ReplaceAll(s, "%", "%25")
+ s = strings.ReplaceAll(s, "\r", "%0D")
+ s = strings.ReplaceAll(s, "\n", "%0A")
+ return s
+}
+
+// escapeGitHubProperty escapes a workflow-command property value.
+func escapeGitHubProperty(s string) string {
+ s = escapeGitHubData(s)
+ s = strings.ReplaceAll(s, ":", "%3A")
+ s = strings.ReplaceAll(s, ",", "%2C")
+ return s
+}
+
+// ParseAnnotationsJSON converts a JSON array of finding objects into
+// annotations using the given field-name mapping. Findings missing the
+// message field are skipped.
+func ParseAnnotationsJSON(data []byte, keys AnnotationKeys) ([]Annotation, error) {
+ var raw []map[string]interface{}
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return nil, fmt.Errorf("parsing findings JSON (want an array of objects): %w", err)
+ }
+ annotations := make([]Annotation, 0, len(raw))
+ for _, item := range raw {
+ msg := stringField(item, keys.Message)
+ if msg == "" {
+ continue
+ }
+ annotations = append(annotations, Annotation{
+ Level: NormalizeAnnotationLevel(stringField(item, keys.Severity)),
+ File: stringField(item, keys.File),
+ Line: intField(item, keys.Line),
+ Col: intField(item, keys.Col),
+ Title: stringField(item, keys.Title),
+ Message: msg,
+ })
+ }
+ return annotations, nil
+}
+
+func stringField(m map[string]interface{}, key string) string {
+ if key == "" {
+ return ""
+ }
+ switch v := m[key].(type) {
+ case string:
+ return v
+ case float64:
+ return strings.TrimSuffix(fmt.Sprintf("%v", v), ".0")
+ default:
+ return ""
+ }
+}
+
+func intField(m map[string]interface{}, key string) int {
+ if key == "" {
+ return 0
+ }
+ switch v := m[key].(type) {
+ case float64:
+ return int(v)
+ case string:
+ n, err := strconv.Atoi(strings.TrimSpace(v))
+ if err != nil {
+ return 0
+ }
+ return n
+ default:
+ return 0
+ }
+}
diff --git a/services/annotate_service_test.go b/services/annotate_service_test.go
new file mode 100644
index 0000000..dc2be8e
--- /dev/null
+++ b/services/annotate_service_test.go
@@ -0,0 +1,88 @@
+package services
+
+import (
+ "testing"
+)
+
+func TestFormatAnnotation_GitHub(t *testing.T) {
+ a := Annotation{
+ Level: "error", File: "src/app.go", Line: 10, Col: 5, EndLine: 12,
+ Title: "lint: unused", Message: "x declared\nbut not used",
+ }
+ got := FormatAnnotation(a, "github-actions")
+ want := "::error file=src/app.go,line=10,endLine=12,col=5,title=lint%3A unused::x declared%0Abut not used"
+ if got != want {
+ t.Errorf("FormatAnnotation() =\n%s\nwant\n%s", got, want)
+ }
+}
+
+func TestFormatAnnotation_GitHubNoProps(t *testing.T) {
+ got := FormatAnnotation(Annotation{Level: "warning", Message: "heads up"}, "github-actions")
+ if got != "::warning::heads up" {
+ t.Errorf("FormatAnnotation() = %q", got)
+ }
+}
+
+func TestFormatAnnotation_Plain(t *testing.T) {
+ a := Annotation{Level: "warning", File: "main.go", Line: 3, Col: 7, Message: "shadowed var"}
+ got := FormatAnnotation(a, "none")
+ if got != "main.go:3:7: warning: shadowed var" {
+ t.Errorf("FormatAnnotation() = %q", got)
+ }
+
+ got = FormatAnnotation(Annotation{Level: "fatal", Message: "boom"}, "gitlab-ci")
+ if got != "error: boom" {
+ t.Errorf("FormatAnnotation() no-file = %q", got)
+ }
+}
+
+func TestNormalizeAnnotationLevel(t *testing.T) {
+ cases := map[string]string{
+ "ERROR": "error", "critical": "error", "warn": "warning",
+ "info": "notice", "": "notice", "style": "notice",
+ }
+ for in, want := range cases {
+ if got := NormalizeAnnotationLevel(in); got != want {
+ t.Errorf("NormalizeAnnotationLevel(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestParseAnnotationsJSON(t *testing.T) {
+ data := `[
+ {"file": "a.go", "line": 3, "col": 1, "severity": "error", "message": "bad"},
+ {"file": "b.go", "line": 9, "severity": "info", "message": "meh", "title": "note"},
+ {"file": "c.go", "line": 1, "severity": "error"}
+ ]`
+ anns, err := ParseAnnotationsJSON([]byte(data), DefaultAnnotationKeys())
+ if err != nil {
+ t.Fatalf("ParseAnnotationsJSON() error = %v", err)
+ }
+ if len(anns) != 2 {
+ t.Fatalf("got %d annotations, want 2 (message-less finding skipped): %v", len(anns), anns)
+ }
+ if anns[0].Level != "error" || anns[0].File != "a.go" || anns[0].Line != 3 {
+ t.Errorf("anns[0] = %+v", anns[0])
+ }
+ if anns[1].Level != "notice" || anns[1].Title != "note" {
+ t.Errorf("anns[1] = %+v", anns[1])
+ }
+}
+
+func TestParseAnnotationsJSON_CustomKeys(t *testing.T) {
+ data := `[{"path": "x.py", "lineNumber": "42", "text": "E501 line too long", "level": "warning"}]`
+ keys := AnnotationKeys{File: "path", Line: "lineNumber", Message: "text", Severity: "level"}
+ anns, err := ParseAnnotationsJSON([]byte(data), keys)
+ if err != nil {
+ t.Fatalf("ParseAnnotationsJSON() error = %v", err)
+ }
+ if len(anns) != 1 || anns[0].File != "x.py" || anns[0].Line != 42 || anns[0].Level != "warning" {
+ t.Errorf("anns = %+v", anns)
+ }
+}
+
+func TestParseAnnotationsJSON_NotArray(t *testing.T) {
+ if _, err := ParseAnnotationsJSON([]byte(`{"file": "a"}`), DefaultAnnotationKeys()); err == nil {
+ t.Error("expected error for non-array input")
+ }
+}
diff --git a/services/artifact_service.go b/services/artifact_service.go
index 7bf236c..a50a7f9 100644
--- a/services/artifact_service.go
+++ b/services/artifact_service.go
@@ -6,6 +6,8 @@ import (
"os"
"path/filepath"
"sort"
+ "strconv"
+ "strings"
"github.com/bmatcuk/doublestar/v4"
)
@@ -100,3 +102,69 @@ func FormatArtifactManifestJSON(entries []ArtifactEntry, pretty bool) (string, e
}
return string(data), nil
}
+
+// ParseSize converts a human-readable size ("5MB", "100KiB", "1.5G", "512")
+// into bytes. Decimal (KB/MB/GB) and binary (KiB/MiB/GiB) units are accepted;
+// bare numbers are bytes.
+func ParseSize(s string) (int64, error) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return 0, fmt.Errorf("empty size")
+ }
+ i := 0
+ for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.') {
+ i++
+ }
+ num, err := strconv.ParseFloat(s[:i], 64)
+ if err != nil {
+ return 0, fmt.Errorf("parsing size %q: %w", s, err)
+ }
+ unit := strings.TrimSpace(strings.ToUpper(s[i:]))
+ multipliers := map[string]float64{
+ "": 1, "B": 1,
+ "K": 1e3, "KB": 1e3, "KIB": 1 << 10,
+ "M": 1e6, "MB": 1e6, "MIB": 1 << 20,
+ "G": 1e9, "GB": 1e9, "GIB": 1 << 30,
+ }
+ mult, ok := multipliers[unit]
+ if !ok {
+ return 0, fmt.Errorf("unknown size unit %q in %q", unit, s)
+ }
+ return int64(num * mult), nil
+}
+
+// FormatSize renders bytes with the most convenient decimal unit.
+func FormatSize(n int64) string {
+ switch {
+ case n >= 1e9:
+ return fmt.Sprintf("%.1fGB", float64(n)/1e9)
+ case n >= 1e6:
+ return fmt.Sprintf("%.1fMB", float64(n)/1e6)
+ case n >= 1e3:
+ return fmt.Sprintf("%.1fKB", float64(n)/1e3)
+ default:
+ return fmt.Sprintf("%dB", n)
+ }
+}
+
+// AssertArtifactSizes fails when any matched file is outside the given size
+// budget. min/max <= 0 disables that bound.
+func AssertArtifactSizes(patterns []string, min, max int64) error {
+ entries, err := ArtifactManifest(patterns)
+ if err != nil {
+ return err
+ }
+ var violations []string
+ for _, e := range entries {
+ if max > 0 && e.Size > max {
+ violations = append(violations, fmt.Sprintf("%s is %s (max %s)", e.Path, FormatSize(e.Size), FormatSize(max)))
+ }
+ if min > 0 && e.Size < min {
+ violations = append(violations, fmt.Sprintf("%s is %s (min %s)", e.Path, FormatSize(e.Size), FormatSize(min)))
+ }
+ }
+ if len(violations) > 0 {
+ return fmt.Errorf("size budget exceeded:\n %s", strings.Join(violations, "\n "))
+ }
+ return nil
+}
diff --git a/services/artifact_service_test.go b/services/artifact_service_test.go
index c9fc766..8c7c607 100644
--- a/services/artifact_service_test.go
+++ b/services/artifact_service_test.go
@@ -9,15 +9,15 @@ import (
func TestArtifactManifest(t *testing.T) {
dir := t.TempDir()
dist := filepath.Join(dir, "dist")
- if err := os.MkdirAll(dist, 0755); err != nil {
+ if err := os.MkdirAll(dist, 0o755); err != nil {
t.Fatal(err)
}
a := filepath.Join(dist, "app-linux-amd64")
b := filepath.Join(dist, "app-darwin-arm64")
- if err := os.WriteFile(a, []byte("linux"), 0644); err != nil {
+ if err := os.WriteFile(a, []byte("linux"), 0o644); err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(b, []byte("darwin"), 0644); err != nil {
+ if err := os.WriteFile(b, []byte("darwin"), 0o644); err != nil {
t.Fatal(err)
}
@@ -41,3 +41,50 @@ func TestAssertArtifactsNoMatch(t *testing.T) {
t.Fatal("expected no-match error")
}
}
+
+func TestParseSize(t *testing.T) {
+ tests := []struct {
+ in string
+ want int64
+ }{
+ {"512", 512},
+ {"5MB", 5_000_000},
+ {"5M", 5_000_000},
+ {"100KB", 100_000},
+ {"1KiB", 1024},
+ {"2MiB", 2 << 20},
+ {"1.5GB", 1_500_000_000},
+ {"1 mb", 1_000_000},
+ }
+ for _, tt := range tests {
+ got, err := ParseSize(tt.in)
+ if err != nil {
+ t.Errorf("ParseSize(%q) error = %v", tt.in, err)
+ continue
+ }
+ if got != tt.want {
+ t.Errorf("ParseSize(%q) = %d, want %d", tt.in, got, tt.want)
+ }
+ }
+ for _, bad := range []string{"", "abc", "5XB"} {
+ if _, err := ParseSize(bad); err == nil {
+ t.Errorf("ParseSize(%q) expected error", bad)
+ }
+ }
+}
+
+func TestAssertArtifactSizes(t *testing.T) {
+ dir := t.TempDir()
+ big := filepath.Join(dir, "big.bin")
+ os.WriteFile(big, make([]byte, 2048), 0o644)
+
+ if err := AssertArtifactSizes([]string{big}, 0, 4096); err != nil {
+ t.Errorf("within budget should pass: %v", err)
+ }
+ if err := AssertArtifactSizes([]string{big}, 0, 1024); err == nil {
+ t.Error("over max-size should fail")
+ }
+ if err := AssertArtifactSizes([]string{big}, 4096, 0); err == nil {
+ t.Error("under min-size should fail")
+ }
+}
diff --git a/services/http_service.go b/services/http_service.go
index b2b6d24..2c27b31 100644
--- a/services/http_service.go
+++ b/services/http_service.go
@@ -353,3 +353,81 @@ func BuildMultipartBody(fileFields []string) ([]byte, string, error) {
}
return b.Bytes(), w.FormDataContentType(), nil
}
+
+// ParseLinkNext extracts the rel="next" target from an RFC 5988 Link header,
+// resolved against the page that served it. Returns "" when there is no next
+// page.
+func ParseLinkNext(header, baseURL string) string {
+ for _, part := range strings.Split(header, ",") {
+ segments := strings.Split(part, ";")
+ if len(segments) < 2 {
+ continue
+ }
+ target := strings.TrimSpace(segments[0])
+ if !strings.HasPrefix(target, "<") || !strings.HasSuffix(target, ">") {
+ continue
+ }
+ isNext := false
+ for _, param := range segments[1:] {
+ param = strings.TrimSpace(param)
+ if v, ok := strings.CutPrefix(param, "rel="); ok {
+ if strings.Trim(v, `"`) == "next" {
+ isNext = true
+ }
+ }
+ }
+ if !isNext {
+ continue
+ }
+ next := strings.TrimSuffix(strings.TrimPrefix(target, "<"), ">")
+ base, err := url.Parse(baseURL)
+ if err != nil {
+ return next
+ }
+ ref, err := url.Parse(next)
+ if err != nil {
+ return next
+ }
+ return base.ResolveReference(ref).String()
+ }
+ return ""
+}
+
+// ExecuteHTTPPaginated GETs a URL and follows Link rel="next" headers,
+// merging the items from every page. itemsPath selects the array inside each
+// response body ("" means the body itself is the array). Returns the merged
+// items and the number of pages fetched.
+func ExecuteHTTPPaginated(opts HTTPRequestOptions, itemsPath string, maxPages int) ([]interface{}, int, error) {
+ if maxPages <= 0 {
+ maxPages = 50
+ }
+ var items []interface{}
+ pages := 0
+ for opts.URL != "" && pages < maxPages {
+ res, err := ExecuteHTTPRequest(opts)
+ if err != nil {
+ return nil, pages, err
+ }
+ pages++
+
+ var pageItems interface{}
+ if itemsPath != "" {
+ pageItems, err = ExtractHTTPJSON(res.Body, itemsPath)
+ if err != nil {
+ return nil, pages, fmt.Errorf("page %d: %w", pages, err)
+ }
+ } else {
+ if err := json.Unmarshal(res.Body, &pageItems); err != nil {
+ return nil, pages, fmt.Errorf("page %d: parsing body: %w", pages, err)
+ }
+ }
+ arr, ok := pageItems.([]interface{})
+ if !ok {
+ return nil, pages, fmt.Errorf("page %d: response is not a JSON array (use --items to point at the array field)", pages)
+ }
+ items = append(items, arr...)
+
+ opts.URL = ParseLinkNext(res.Headers.Get("Link"), opts.URL)
+ }
+ return items, pages, nil
+}
diff --git a/services/http_service_test.go b/services/http_service_test.go
index 2154303..09b279c 100644
--- a/services/http_service_test.go
+++ b/services/http_service_test.go
@@ -2,6 +2,7 @@ package services
import (
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -137,3 +138,69 @@ func TestParseHTTPHeadersRejectsInvalid(t *testing.T) {
t.Fatalf("expected invalid header error, got %v", err)
}
}
+
+func TestParseLinkNext(t *testing.T) {
+ header := `; rel="next", ; rel="last"`
+ got := ParseLinkNext(header, "https://api.example.com/items?page=2")
+ if got != "https://api.example.com/items?page=3" {
+ t.Errorf("ParseLinkNext() = %q", got)
+ }
+
+ if got := ParseLinkNext(`; rel="next"`, "https://api.example.com/items"); got != "https://api.example.com/items?page=2" {
+ t.Errorf("ParseLinkNext() relative = %q", got)
+ }
+
+ if got := ParseLinkNext(`; rel="prev"`, "https://api.example.com"); got != "" {
+ t.Errorf("ParseLinkNext() without next = %q, want empty", got)
+ }
+ if got := ParseLinkNext("", "https://api.example.com"); got != "" {
+ t.Errorf("ParseLinkNext(empty) = %q, want empty", got)
+ }
+}
+
+func TestExecuteHTTPPaginated(t *testing.T) {
+ var srv *httptest.Server
+ srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ page := r.URL.Query().Get("page")
+ switch page {
+ case "", "1":
+ w.Header().Set("Link", "<"+srv.URL+"/items?page=2>; rel=\"next\"")
+ fmt.Fprint(w, `[{"id": 1}, {"id": 2}]`)
+ case "2":
+ w.Header().Set("Link", "<"+srv.URL+"/items?page=3>; rel=\"next\"")
+ fmt.Fprint(w, `[{"id": 3}]`)
+ default:
+ fmt.Fprint(w, `[]`)
+ }
+ }))
+ defer srv.Close()
+
+ items, pages, err := ExecuteHTTPPaginated(HTTPRequestOptions{URL: srv.URL + "/items"}, "", 0)
+ if err != nil {
+ t.Fatalf("ExecuteHTTPPaginated() error = %v", err)
+ }
+ if pages != 3 || len(items) != 3 {
+ t.Errorf("pages = %d items = %d, want 3 pages / 3 items", pages, len(items))
+ }
+}
+
+func TestExecuteHTTPPaginated_ItemsPathAndMaxPages(t *testing.T) {
+ var srv *httptest.Server
+ srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Link", "<"+srv.URL+">; rel=\"next\"") // endless pagination
+ fmt.Fprint(w, `{"data": [{"id": 1}], "total": 100}`)
+ }))
+ defer srv.Close()
+
+ items, pages, err := ExecuteHTTPPaginated(HTTPRequestOptions{URL: srv.URL}, ".data", 4)
+ if err != nil {
+ t.Fatalf("ExecuteHTTPPaginated() error = %v", err)
+ }
+ if pages != 4 || len(items) != 4 {
+ t.Errorf("pages = %d items = %d, want capped at 4", pages, len(items))
+ }
+
+ if _, _, err := ExecuteHTTPPaginated(HTTPRequestOptions{URL: srv.URL}, ".total", 1); err == nil {
+ t.Error("expected error when items path is not an array")
+ }
+}
diff --git a/services/lock_service.go b/services/lock_service.go
new file mode 100644
index 0000000..9cba7a6
--- /dev/null
+++ b/services/lock_service.go
@@ -0,0 +1,106 @@
+package services
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "time"
+)
+
+// lockInfo is what gets written into the lock file for debuggability.
+type lockInfo struct {
+ PID int `json:"pid"`
+ Acquired string `json:"acquired"`
+ Host string `json:"host,omitempty"`
+}
+
+// AcquireLock takes a file-based mutex. It retries until timeout elapses.
+// When stale > 0, an existing lock file older than stale is treated as
+// abandoned and taken over; stale == 0 never steals a lock.
+func AcquireLock(path string, timeout, stale time.Duration) error {
+ deadline := time.Now().Add(timeout)
+ for {
+ if err := tryCreateLock(path); err == nil {
+ return nil
+ }
+
+ if stale > 0 {
+ if info, err := os.Stat(path); err == nil && time.Since(info.ModTime()) > stale {
+ // Best-effort takeover: remove and race for it on the next attempt.
+ _ = os.Remove(path)
+ continue
+ }
+ }
+
+ if time.Now().After(deadline) {
+ holder := describeLockHolder(path)
+ return fmt.Errorf("could not acquire lock %s within %s%s", path, timeout, holder)
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+}
+
+func tryCreateLock(path string) error {
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ host, _ := os.Hostname()
+ data, _ := json.Marshal(lockInfo{
+ PID: os.Getpid(),
+ Acquired: time.Now().UTC().Format(time.RFC3339),
+ Host: host,
+ })
+ _, err = f.Write(append(data, '\n'))
+ return err
+}
+
+func describeLockHolder(path string) string {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ var info lockInfo
+ if json.Unmarshal(data, &info) != nil {
+ return ""
+ }
+ return fmt.Sprintf(" (held by pid %d since %s)", info.PID, info.Acquired)
+}
+
+// ReleaseLock removes the lock file. A missing file is not an error, so
+// release is safe to call from an always-run cleanup step.
+func ReleaseLock(path string) error {
+ err := os.Remove(path)
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+}
+
+// RunWithLock acquires the lock, runs the command with inherited stdio, and
+// releases the lock afterwards (also on command failure). Returns the
+// command's exit code.
+func RunWithLock(path string, timeout, stale time.Duration, args []string) (int, error) {
+ if len(args) == 0 {
+ return 1, fmt.Errorf("no command given")
+ }
+ if err := AcquireLock(path, timeout, stale); err != nil {
+ return 1, err
+ }
+ defer func() { _ = ReleaseLock(path) }()
+
+ cmd := exec.Command(args[0], args[1:]...)
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ err := cmd.Run()
+ if err == nil {
+ return 0, nil
+ }
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ return exitErr.ExitCode(), nil
+ }
+ return 1, err
+}
diff --git a/services/lock_service_test.go b/services/lock_service_test.go
new file mode 100644
index 0000000..6cd33eb
--- /dev/null
+++ b/services/lock_service_test.go
@@ -0,0 +1,82 @@
+package services
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestAcquireRelease(t *testing.T) {
+ lock := filepath.Join(t.TempDir(), "deploy.lock")
+ if err := AcquireLock(lock, time.Second, 0); err != nil {
+ t.Fatalf("AcquireLock() error = %v", err)
+ }
+ data, err := os.ReadFile(lock)
+ if err != nil {
+ t.Fatalf("lock file missing: %v", err)
+ }
+ if len(data) == 0 {
+ t.Error("lock file should record holder info")
+ }
+ if err := ReleaseLock(lock); err != nil {
+ t.Fatalf("ReleaseLock() error = %v", err)
+ }
+ if _, err := os.Stat(lock); !os.IsNotExist(err) {
+ t.Error("lock file should be gone after release")
+ }
+}
+
+func TestAcquire_Contention(t *testing.T) {
+ lock := filepath.Join(t.TempDir(), "deploy.lock")
+ if err := AcquireLock(lock, time.Second, 0); err != nil {
+ t.Fatalf("first AcquireLock() error = %v", err)
+ }
+ start := time.Now()
+ err := AcquireLock(lock, 300*time.Millisecond, 0)
+ if err == nil {
+ t.Fatal("second AcquireLock() should fail while lock is held")
+ }
+ if time.Since(start) < 300*time.Millisecond {
+ t.Errorf("AcquireLock() gave up before the timeout: %v", time.Since(start))
+ }
+}
+
+func TestAcquire_StaleTakeover(t *testing.T) {
+ lock := filepath.Join(t.TempDir(), "deploy.lock")
+ if err := AcquireLock(lock, time.Second, 0); err != nil {
+ t.Fatalf("AcquireLock() error = %v", err)
+ }
+ old := time.Now().Add(-time.Hour)
+ if err := os.Chtimes(lock, old, old); err != nil {
+ t.Fatalf("Chtimes() error = %v", err)
+ }
+ if err := AcquireLock(lock, time.Second, 10*time.Minute); err != nil {
+ t.Errorf("AcquireLock() should steal a stale lock: %v", err)
+ }
+}
+
+func TestReleaseLock_Missing(t *testing.T) {
+ if err := ReleaseLock(filepath.Join(t.TempDir(), "nope.lock")); err != nil {
+ t.Errorf("ReleaseLock() on missing file = %v, want nil", err)
+ }
+}
+
+func TestRunWithLock(t *testing.T) {
+ lock := filepath.Join(t.TempDir(), "run.lock")
+ code, err := RunWithLock(lock, time.Second, 0, []string{"true"})
+ if err != nil || code != 0 {
+ t.Errorf("RunWithLock(true) = %d, %v", code, err)
+ }
+ if _, statErr := os.Stat(lock); !os.IsNotExist(statErr) {
+ t.Error("lock should be released after the command exits")
+ }
+
+ code, err = RunWithLock(lock, time.Second, 0, []string{"false"})
+ if err != nil || code == 0 {
+ t.Errorf("RunWithLock(false) = %d, %v; want non-zero exit, nil error", code, err)
+ }
+ if _, statErr := os.Stat(lock); !os.IsNotExist(statErr) {
+ t.Error("lock should be released even when the command fails")
+ }
+}
diff --git a/services/multidoc_service.go b/services/multidoc_service.go
new file mode 100644
index 0000000..4c39400
--- /dev/null
+++ b/services/multidoc_service.go
@@ -0,0 +1,216 @@
+package services
+
+import (
+ "bytes"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+// YAMLDocSpan is one document inside a multi-document YAML stream, addressed
+// by its byte range in the original data so edits can be spliced back without
+// touching sibling documents.
+type YAMLDocSpan struct {
+ Start int
+ End int
+ Doc interface{} // decoded content, nil for empty documents
+}
+
+// SplitYAMLDocs splits a YAML stream on `---` document markers and decodes
+// each document. Spans cover the document bodies only, never the marker
+// lines, so re-joining edited spans preserves the original separators.
+func SplitYAMLDocs(data []byte) ([]YAMLDocSpan, error) {
+ var spans []YAMLDocSpan
+ lineStart := 0
+ docStart := 0
+
+ flush := func(end int) error {
+ body := data[docStart:end]
+ var doc interface{}
+ if err := yaml.Unmarshal(body, &doc); err != nil {
+ return fmt.Errorf("parsing document at byte %d: %w", docStart, err)
+ }
+ spans = append(spans, YAMLDocSpan{Start: docStart, End: end, Doc: normalizeMaps(doc)})
+ return nil
+ }
+
+ for lineStart <= len(data) {
+ lineEnd := len(data)
+ if idx := bytes.IndexByte(data[lineStart:], '\n'); idx >= 0 {
+ lineEnd = lineStart + idx + 1
+ }
+ line := data[lineStart:lineEnd]
+ trimmed := strings.TrimRight(string(line), "\r\n")
+ if trimmed == "---" || strings.HasPrefix(trimmed, "--- ") {
+ if err := flush(lineStart); err != nil {
+ return nil, err
+ }
+ docStart = lineEnd
+ }
+ if lineEnd == len(data) {
+ break
+ }
+ lineStart = lineEnd
+ }
+ if err := flush(len(data)); err != nil {
+ return nil, err
+ }
+ return spans, nil
+}
+
+// SelectYAMLDoc resolves a --doc selector against a stream. The selector is
+// either a zero-based index over non-empty documents ("1"), or a
+// comma-separated list of dotpath=value matches ("kind=Deployment,metadata.name=api").
+// Exactly one document must match.
+func SelectYAMLDoc(data []byte, selector string) (YAMLDocSpan, error) {
+ spans, err := SplitYAMLDocs(data)
+ if err != nil {
+ return YAMLDocSpan{}, err
+ }
+ var real []YAMLDocSpan
+ for _, s := range spans {
+ if s.Doc != nil {
+ real = append(real, s)
+ }
+ }
+ if len(real) == 0 {
+ return YAMLDocSpan{}, fmt.Errorf("no YAML documents found")
+ }
+
+ if idx, err := strconv.Atoi(strings.TrimSpace(selector)); err == nil {
+ if idx < 0 || idx >= len(real) {
+ return YAMLDocSpan{}, fmt.Errorf("document index %d out of range (stream has %d documents)", idx, len(real))
+ }
+ return real[idx], nil
+ }
+
+ matchers, err := parseDocSelector(selector)
+ if err != nil {
+ return YAMLDocSpan{}, err
+ }
+ var matches []YAMLDocSpan
+ for _, s := range real {
+ if docMatches(s.Doc, matchers) {
+ matches = append(matches, s)
+ }
+ }
+ switch len(matches) {
+ case 0:
+ return YAMLDocSpan{}, fmt.Errorf("no document matches selector %q", selector)
+ case 1:
+ return matches[0], nil
+ default:
+ return YAMLDocSpan{}, fmt.Errorf("selector %q matches %d documents; add more fields (e.g. metadata.name=...)", selector, len(matches))
+ }
+}
+
+type docMatcher struct {
+ path string
+ value string
+}
+
+func parseDocSelector(selector string) ([]docMatcher, error) {
+ parts := strings.Split(selector, ",")
+ matchers := make([]docMatcher, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ kv := strings.SplitN(p, "=", 2)
+ if len(kv) != 2 || kv[0] == "" {
+ return nil, fmt.Errorf("bad selector part %q: want dotpath=value or a document index", p)
+ }
+ path := kv[0]
+ if !strings.HasPrefix(path, ".") {
+ path = "." + path
+ }
+ matchers = append(matchers, docMatcher{path: path, value: kv[1]})
+ }
+ if len(matchers) == 0 {
+ return nil, fmt.Errorf("empty selector")
+ }
+ return matchers, nil
+}
+
+func docMatches(doc interface{}, matchers []docMatcher) bool {
+ for _, m := range matchers {
+ val, err := JSONGet(doc, m.path)
+ if err != nil || val == nil {
+ return false
+ }
+ if fmt.Sprintf("%v", val) != m.value {
+ return false
+ }
+ }
+ return true
+}
+
+// MultiDocGet extracts a value at path from the document picked by selector.
+func MultiDocGet(data []byte, selector, path string) (interface{}, error) {
+ span, err := SelectYAMLDoc(data, selector)
+ if err != nil {
+ return nil, err
+ }
+ return JSONGet(span.Doc, path)
+}
+
+// MultiDocEdit applies edit to the selected document's bytes and splices the
+// result back, leaving every other document byte-identical. The edit callback
+// receives only the selected document's body.
+func MultiDocEdit(data []byte, selector string, edit func(doc []byte) ([]byte, error)) ([]byte, error) {
+ span, err := SelectYAMLDoc(data, selector)
+ if err != nil {
+ return nil, err
+ }
+ edited, err := edit(data[span.Start:span.End])
+ if err != nil {
+ return nil, err
+ }
+ out := make([]byte, 0, len(data)-(span.End-span.Start)+len(edited))
+ out = append(out, data[:span.Start]...)
+ out = append(out, edited...)
+ out = append(out, data[span.End:]...)
+ return out, nil
+}
+
+// MultiDocSet sets path=value in the selected document. With preserve, the
+// edit is surgical (comments and formatting kept); otherwise the selected
+// document is re-encoded. Sibling documents are never rewritten.
+func MultiDocSet(data []byte, selector, path string, value interface{}, preserve bool) ([]byte, error) {
+ return MultiDocEdit(data, selector, func(doc []byte) ([]byte, error) {
+ if preserve {
+ return SetPreserving(doc, FormatYAML, path, value)
+ }
+ return reencodeYAMLDoc(doc, func(d interface{}) (interface{}, error) {
+ return JSONSet(d, path, value)
+ })
+ })
+}
+
+// MultiDocDel removes path from the selected document; see MultiDocSet for
+// preserve semantics.
+func MultiDocDel(data []byte, selector, path string, preserve bool) ([]byte, error) {
+ return MultiDocEdit(data, selector, func(doc []byte) ([]byte, error) {
+ if preserve {
+ return DelPreserving(doc, FormatYAML, path)
+ }
+ return reencodeYAMLDoc(doc, func(d interface{}) (interface{}, error) {
+ return JSONDel(d, path)
+ })
+ })
+}
+
+func reencodeYAMLDoc(doc []byte, mutate func(interface{}) (interface{}, error)) ([]byte, error) {
+ decoded, err := Decode(doc, FormatYAML)
+ if err != nil {
+ return nil, err
+ }
+ updated, err := mutate(decoded)
+ if err != nil {
+ return nil, err
+ }
+ return Encode(updated, FormatYAML, true)
+}
diff --git a/services/multidoc_service_test.go b/services/multidoc_service_test.go
new file mode 100644
index 0000000..e6b4982
--- /dev/null
+++ b/services/multidoc_service_test.go
@@ -0,0 +1,177 @@
+package services
+
+import (
+ "strings"
+ "testing"
+)
+
+const k8sStream = `# Deployment for the api service
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: api # inline comment
+spec:
+ replicas: 2
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: api
+spec:
+ ports:
+ - port: 80
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: worker
+spec:
+ replicas: 1
+`
+
+func TestSplitYAMLDocs(t *testing.T) {
+ spans, err := SplitYAMLDocs([]byte(k8sStream))
+ if err != nil {
+ t.Fatalf("SplitYAMLDocs() error = %v", err)
+ }
+ if len(spans) != 3 {
+ t.Fatalf("got %d spans, want 3", len(spans))
+ }
+ for i, s := range spans {
+ if s.Doc == nil {
+ t.Errorf("span %d decoded to nil", i)
+ }
+ }
+}
+
+func TestSplitYAMLDocs_LeadingMarker(t *testing.T) {
+ data := "---\na: 1\n---\nb: 2\n"
+ spans, err := SplitYAMLDocs([]byte(data))
+ if err != nil {
+ t.Fatalf("SplitYAMLDocs() error = %v", err)
+ }
+ real := 0
+ for _, s := range spans {
+ if s.Doc != nil {
+ real++
+ }
+ }
+ if real != 2 {
+ t.Errorf("got %d non-empty docs, want 2 (leading --- yields an empty span)", real)
+ }
+}
+
+func TestSelectYAMLDoc_ByIndex(t *testing.T) {
+ span, err := SelectYAMLDoc([]byte(k8sStream), "1")
+ if err != nil {
+ t.Fatalf("SelectYAMLDoc() error = %v", err)
+ }
+ kind, _ := JSONGet(span.Doc, ".kind")
+ if kind != "Service" {
+ t.Errorf("doc 1 kind = %v, want Service", kind)
+ }
+}
+
+func TestSelectYAMLDoc_ByFields(t *testing.T) {
+ span, err := SelectYAMLDoc([]byte(k8sStream), "kind=Deployment,metadata.name=worker")
+ if err != nil {
+ t.Fatalf("SelectYAMLDoc() error = %v", err)
+ }
+ name, _ := JSONGet(span.Doc, ".metadata.name")
+ if name != "worker" {
+ t.Errorf("selected doc name = %v, want worker", name)
+ }
+}
+
+func TestSelectYAMLDoc_Ambiguous(t *testing.T) {
+ if _, err := SelectYAMLDoc([]byte(k8sStream), "kind=Deployment"); err == nil {
+ t.Error("expected ambiguity error for kind=Deployment (2 matches)")
+ }
+ if _, err := SelectYAMLDoc([]byte(k8sStream), "kind=CronJob"); err == nil {
+ t.Error("expected no-match error for kind=CronJob")
+ }
+ if _, err := SelectYAMLDoc([]byte(k8sStream), "9"); err == nil {
+ t.Error("expected out-of-range error for index 9")
+ }
+ if _, err := SelectYAMLDoc([]byte(k8sStream), "kindDeployment"); err == nil {
+ t.Error("expected parse error for selector without =")
+ }
+}
+
+func TestMultiDocGet(t *testing.T) {
+ val, err := MultiDocGet([]byte(k8sStream), "kind=Service", ".spec.ports[0].port")
+ if err != nil {
+ t.Fatalf("MultiDocGet() error = %v", err)
+ }
+ if got := toFloat(val); got != 80 {
+ t.Errorf("MultiDocGet() = %v, want 80", val)
+ }
+}
+
+func toFloat(v interface{}) float64 {
+ switch n := v.(type) {
+ case float64:
+ return n
+ case int:
+ return float64(n)
+ default:
+ return -1
+ }
+}
+
+func TestMultiDocSet_PreserveKeepsSiblingsAndComments(t *testing.T) {
+ out, err := MultiDocSet([]byte(k8sStream), "kind=Deployment,metadata.name=api", ".spec.replicas", 5, true)
+ if err != nil {
+ t.Fatalf("MultiDocSet() error = %v", err)
+ }
+ got := string(out)
+
+ if !strings.Contains(got, "replicas: 5") {
+ t.Errorf("edit missing:\n%s", got)
+ }
+ if !strings.Contains(got, "# Deployment for the api service") ||
+ !strings.Contains(got, "name: api # inline comment") {
+ t.Errorf("comments lost:\n%s", got)
+ }
+ // Sibling docs must be byte-identical.
+ for _, sibling := range []string{
+ "apiVersion: v1\nkind: Service\nmetadata:\n name: api\nspec:\n ports:\n - port: 80\n",
+ "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: worker\nspec:\n replicas: 1\n",
+ } {
+ if !strings.Contains(got, sibling) {
+ t.Errorf("sibling document rewritten:\n%s", got)
+ }
+ }
+ // Still 3 documents.
+ if strings.Count(got, "\n---\n")+strings.Count(got, "\n---\r\n") != 2 {
+ t.Errorf("document separators changed:\n%s", got)
+ }
+}
+
+func TestMultiDocSet_NoPreserve(t *testing.T) {
+ out, err := MultiDocSet([]byte(k8sStream), "1", ".spec.ports[0].port", 8080, false)
+ if err != nil {
+ t.Fatalf("MultiDocSet() error = %v", err)
+ }
+ got := string(out)
+ if !strings.Contains(got, "port: 8080") {
+ t.Errorf("edit missing:\n%s", got)
+ }
+ if !strings.Contains(got, "# Deployment for the api service") {
+ t.Errorf("untouched sibling lost its comment:\n%s", got)
+ }
+}
+
+func TestMultiDocDel(t *testing.T) {
+ out, err := MultiDocDel([]byte(k8sStream), "kind=Deployment,metadata.name=worker", ".spec.replicas", true)
+ if err != nil {
+ t.Fatalf("MultiDocDel() error = %v", err)
+ }
+ got := string(out)
+ if strings.Contains(got, "replicas: 1") {
+ t.Errorf("delete did not remove target:\n%s", got)
+ }
+ if !strings.Contains(got, "replicas: 2") {
+ t.Errorf("delete touched the wrong document:\n%s", got)
+ }
+}
diff --git a/services/render_service.go b/services/render_service.go
index f73b7ad..68655a2 100644
--- a/services/render_service.go
+++ b/services/render_service.go
@@ -264,3 +264,76 @@ func buildTemplateData(values map[string]interface{}) map[string]interface{} {
"Env": envMap,
}
}
+
+// EnvsubstOptions configures Envsubst.
+type EnvsubstOptions struct {
+ // Strict fails on ${VAR} references with no value and no default.
+ Strict bool
+ // Lookup resolves a variable name; defaults to os.LookupEnv.
+ Lookup func(string) (string, bool)
+}
+
+var envsubstRef = regexp.MustCompile(`\$(\w+)|\$\{(\w+)(?::-([^}]*))?\}`)
+
+// Envsubst replaces $VAR and ${VAR} references with environment values,
+// supporting ${VAR:-default} fallbacks — a portable replacement for the
+// gettext envsubst binary. $$ escapes a literal dollar sign.
+func Envsubst(input string, opts EnvsubstOptions) (string, error) {
+ lookup := opts.Lookup
+ if lookup == nil {
+ lookup = os.LookupEnv
+ }
+
+ var missing []string
+ var b strings.Builder
+ rest := input
+ for len(rest) > 0 {
+ dollar := strings.IndexByte(rest, '$')
+ if dollar < 0 {
+ b.WriteString(rest)
+ break
+ }
+ b.WriteString(rest[:dollar])
+ rest = rest[dollar:]
+
+ if strings.HasPrefix(rest, "$$") {
+ b.WriteByte('$')
+ rest = rest[2:]
+ continue
+ }
+
+ loc := envsubstRef.FindStringSubmatchIndex(rest)
+ if loc == nil || loc[0] != 0 {
+ b.WriteByte('$')
+ rest = rest[1:]
+ continue
+ }
+ name := firstSubmatch(rest, loc, 1, 2)
+ value, ok := lookup(name)
+ if !ok && loc[6] >= 0 { // ${VAR:-default}
+ value = rest[loc[6]:loc[7]]
+ ok = true
+ }
+ if !ok && opts.Strict {
+ missing = append(missing, name)
+ }
+ // Like gettext envsubst, an unset variable without a default renders
+ // as empty (unless strict already flagged it).
+ b.WriteString(value)
+ rest = rest[loc[1]:]
+ }
+
+ if len(missing) > 0 {
+ return "", fmt.Errorf("unset variable(s): %s", strings.Join(missing, ", "))
+ }
+ return b.String(), nil
+}
+
+func firstSubmatch(s string, loc []int, groups ...int) string {
+ for _, g := range groups {
+ if loc[2*g] >= 0 {
+ return s[loc[2*g]:loc[2*g+1]]
+ }
+ }
+ return ""
+}
diff --git a/services/render_service_test.go b/services/render_service_test.go
index 20517e0..1edb322 100644
--- a/services/render_service_test.go
+++ b/services/render_service_test.go
@@ -97,7 +97,7 @@ func TestRenderTemplateString_Basics(t *testing.T) {
func TestRenderTemplateFile(t *testing.T) {
dir := t.TempDir()
tmpl := filepath.Join(dir, "values.yaml.tpl")
- os.WriteFile(tmpl, []byte("image:\n tag: {{ .Values.tag }}\n"), 0644)
+ os.WriteFile(tmpl, []byte("image:\n tag: {{ .Values.tag }}\n"), 0o644)
got, err := RenderTemplateFile(tmpl, map[string]interface{}{"tag": "v1.2.3"})
if err != nil {
@@ -126,7 +126,7 @@ func TestApplySetOverrides(t *testing.T) {
func TestLoadValues(t *testing.T) {
dir := t.TempDir()
yamlFile := filepath.Join(dir, "v.yaml")
- os.WriteFile(yamlFile, []byte("image:\n tag: v1.0.0\n"), 0644)
+ os.WriteFile(yamlFile, []byte("image:\n tag: v1.0.0\n"), 0o644)
got, err := LoadValues(yamlFile)
if err != nil {
t.Fatalf("error: %v", err)
@@ -135,3 +135,53 @@ func TestLoadValues(t *testing.T) {
t.Errorf("got: %v", got)
}
}
+
+func TestEnvsubst(t *testing.T) {
+ lookup := func(name string) (string, bool) {
+ vals := map[string]string{"IMAGE": "app:v2", "ENV": "prod"}
+ v, ok := vals[name]
+ return v, ok
+ }
+
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {"braced", "image: ${IMAGE}", "image: app:v2"},
+ {"bare", "env: $ENV", "env: prod"},
+ {"default used", "region: ${REGION:-eu-west-1}", "region: eu-west-1"},
+ {"default ignored when set", "env: ${ENV:-dev}", "env: prod"},
+ {"unset renders empty", "x: [${MISSING}]", "x: []"},
+ {"escaped dollar", "cost: $$5 for ${ENV}", "cost: $5 for prod"},
+ {"lone dollar", "a $ sign", "a $ sign"},
+ {"empty default", "v: ${MISSING:-}", "v: "},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Envsubst(tt.input, EnvsubstOptions{Lookup: lookup})
+ if err != nil {
+ t.Fatalf("Envsubst() error = %v", err)
+ }
+ if got != tt.want {
+ t.Errorf("Envsubst(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestEnvsubst_Strict(t *testing.T) {
+ lookup := func(string) (string, bool) { return "", false }
+ _, err := Envsubst("a: ${FOO}\nb: $BAR\nc: ${OK:-x}", EnvsubstOptions{Strict: true, Lookup: lookup})
+ if err == nil {
+ t.Fatal("Envsubst() strict should fail on unset vars")
+ }
+ for _, name := range []string{"FOO", "BAR"} {
+ if !strings.Contains(err.Error(), name) {
+ t.Errorf("error %q should mention %s", err, name)
+ }
+ }
+ if strings.Contains(err.Error(), "OK") {
+ t.Errorf("error %q should not mention OK (has default)", err)
+ }
+}
diff --git a/services/report_service.go b/services/report_service.go
new file mode 100644
index 0000000..dc31cde
--- /dev/null
+++ b/services/report_service.go
@@ -0,0 +1,281 @@
+package services
+
+import (
+ "encoding/json"
+ "encoding/xml"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// JUnitCase is a single test case pulled from a JUnit XML report.
+type JUnitCase struct {
+ Name string `json:"name"`
+ ClassName string `json:"classname,omitempty"`
+ Time float64 `json:"time"`
+ Status string `json:"status"` // passed, failed, error, skipped
+ Message string `json:"message,omitempty"`
+}
+
+// JUnitReport aggregates one or more JUnit test suites.
+type JUnitReport struct {
+ Total int `json:"total"`
+ Passed int `json:"passed"`
+ Failures int `json:"failures"`
+ Errors int `json:"errors"`
+ Skipped int `json:"skipped"`
+ Duration float64 `json:"duration"`
+ Failed []JUnitCase `json:"failed,omitempty"`
+ Cases []JUnitCase `json:"-"`
+}
+
+type junitXMLResult struct {
+ Message string `xml:"message,attr"`
+ Body string `xml:",chardata"`
+}
+
+type junitXMLCase struct {
+ Name string `xml:"name,attr"`
+ ClassName string `xml:"classname,attr"`
+ Time string `xml:"time,attr"`
+ Failure *junitXMLResult `xml:"failure"`
+ Error *junitXMLResult `xml:"error"`
+ Skipped *junitXMLResult `xml:"skipped"`
+}
+
+type junitXMLSuite struct {
+ Name string `xml:"name,attr"`
+ Suites []junitXMLSuite `xml:"testsuite"`
+ Cases []junitXMLCase `xml:"testcase"`
+}
+
+type junitXMLSuites struct {
+ Suites []junitXMLSuite `xml:"testsuite"`
+}
+
+// ParseJUnit parses a JUnit XML report. Both and a bare
+// root are accepted; nested suites are flattened.
+func ParseJUnit(data []byte) (*JUnitReport, error) {
+ root := struct {
+ XMLName xml.Name
+ }{}
+ if err := xml.Unmarshal(data, &root); err != nil {
+ return nil, fmt.Errorf("parsing JUnit XML: %w", err)
+ }
+
+ var suites []junitXMLSuite
+ switch root.XMLName.Local {
+ case "testsuites":
+ var doc junitXMLSuites
+ if err := xml.Unmarshal(data, &doc); err != nil {
+ return nil, fmt.Errorf("parsing JUnit XML: %w", err)
+ }
+ suites = doc.Suites
+ case "testsuite":
+ var doc junitXMLSuite
+ if err := xml.Unmarshal(data, &doc); err != nil {
+ return nil, fmt.Errorf("parsing JUnit XML: %w", err)
+ }
+ suites = []junitXMLSuite{doc}
+ default:
+ return nil, fmt.Errorf("unexpected root element <%s>: want or ", root.XMLName.Local)
+ }
+
+ report := &JUnitReport{}
+ for _, s := range suites {
+ collectJUnitSuite(s, report)
+ }
+ return report, nil
+}
+
+func collectJUnitSuite(s junitXMLSuite, report *JUnitReport) {
+ for _, nested := range s.Suites {
+ collectJUnitSuite(nested, report)
+ }
+ for _, c := range s.Cases {
+ kase := JUnitCase{
+ Name: c.Name,
+ ClassName: c.ClassName,
+ Time: parseJUnitTime(c.Time),
+ Status: "passed",
+ }
+ switch {
+ case c.Error != nil:
+ kase.Status = "error"
+ kase.Message = junitMessage(c.Error)
+ report.Errors++
+ report.Failed = append(report.Failed, kase)
+ case c.Failure != nil:
+ kase.Status = "failed"
+ kase.Message = junitMessage(c.Failure)
+ report.Failures++
+ report.Failed = append(report.Failed, kase)
+ case c.Skipped != nil:
+ kase.Status = "skipped"
+ report.Skipped++
+ default:
+ report.Passed++
+ }
+ report.Total++
+ report.Duration += kase.Time
+ report.Cases = append(report.Cases, kase)
+ }
+}
+
+func junitMessage(r *junitXMLResult) string {
+ msg := strings.TrimSpace(r.Message)
+ if msg == "" {
+ msg = strings.TrimSpace(r.Body)
+ }
+ if idx := strings.IndexByte(msg, '\n'); idx >= 0 {
+ msg = msg[:idx]
+ }
+ return msg
+}
+
+func parseJUnitTime(s string) float64 {
+ s = strings.ReplaceAll(strings.TrimSpace(s), ",", "")
+ v, err := strconv.ParseFloat(s, 64)
+ if err != nil {
+ return 0
+ }
+ return v
+}
+
+// SlowestJUnitCases returns the n slowest cases, slowest first.
+func SlowestJUnitCases(report *JUnitReport, n int) []JUnitCase {
+ cases := make([]JUnitCase, len(report.Cases))
+ copy(cases, report.Cases)
+ sort.SliceStable(cases, func(i, j int) bool { return cases[i].Time > cases[j].Time })
+ if n > len(cases) {
+ n = len(cases)
+ }
+ return cases[:n]
+}
+
+// FormatJUnitMarkdown renders a report as markdown for step summaries and
+// PR comments. slowest > 0 appends a slowest-tests table.
+func FormatJUnitMarkdown(report *JUnitReport, slowest int) string {
+ var b strings.Builder
+ status := "✅"
+ if report.Failures+report.Errors > 0 {
+ status = "❌"
+ }
+ fmt.Fprintf(&b, "%s **%d tests** — %d passed, %d failed, %d errors, %d skipped (%.2fs)\n",
+ status, report.Total, report.Passed, report.Failures, report.Errors, report.Skipped, report.Duration)
+
+ if len(report.Failed) > 0 {
+ b.WriteString("\n| Test | Status | Message |\n|---|---|---|\n")
+ for _, c := range report.Failed {
+ fmt.Fprintf(&b, "| `%s` | %s | %s |\n",
+ escapeMarkdownCell(junitCaseID(c)), c.Status, escapeMarkdownCell(c.Message))
+ }
+ }
+
+ if slowest > 0 && report.Total > 0 {
+ b.WriteString("\nSlowest tests
\n\n| Test | Duration |\n|---|---|\n")
+ for _, c := range SlowestJUnitCases(report, slowest) {
+ fmt.Fprintf(&b, "| `%s` | %.2fs |\n", escapeMarkdownCell(junitCaseID(c)), c.Time)
+ }
+ b.WriteString("\n \n")
+ }
+ return b.String()
+}
+
+func junitCaseID(c JUnitCase) string {
+ if c.ClassName != "" {
+ return c.ClassName + "/" + c.Name
+ }
+ return c.Name
+}
+
+// FormatJUnitJSON renders the aggregate counts and failed cases as JSON.
+func FormatJUnitJSON(report *JUnitReport) (string, error) {
+ data, err := json.Marshal(report)
+ if err != nil {
+ return "", err
+ }
+ return string(data) + "\n", nil
+}
+
+// ParseCoverage extracts a total line-coverage percentage (0–100) from a
+// coverage report. format is lcov, cobertura, or auto (sniff the content).
+func ParseCoverage(data []byte, format string) (float64, error) {
+ switch strings.ToLower(format) {
+ case "lcov":
+ return parseLcov(data)
+ case "cobertura", "xml":
+ return parseCobertura(data)
+ case "", "auto":
+ trimmed := strings.TrimSpace(string(data))
+ if strings.HasPrefix(trimmed, "<") {
+ return parseCobertura(data)
+ }
+ return parseLcov(data)
+ default:
+ return 0, fmt.Errorf("unknown coverage format: %s (use lcov, cobertura, auto)", format)
+ }
+}
+
+func parseLcov(data []byte) (float64, error) {
+ var found, hit int
+ seen := false
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ switch {
+ case strings.HasPrefix(line, "LF:"):
+ v, err := strconv.Atoi(line[3:])
+ if err != nil {
+ return 0, fmt.Errorf("parsing lcov LF line %q: %w", line, err)
+ }
+ found += v
+ seen = true
+ case strings.HasPrefix(line, "LH:"):
+ v, err := strconv.Atoi(line[3:])
+ if err != nil {
+ return 0, fmt.Errorf("parsing lcov LH line %q: %w", line, err)
+ }
+ hit += v
+ seen = true
+ }
+ }
+ if !seen {
+ return 0, fmt.Errorf("no LF/LH records found: not an lcov report?")
+ }
+ if found == 0 {
+ return 0, nil
+ }
+ return float64(hit) / float64(found) * 100, nil
+}
+
+func parseCobertura(data []byte) (float64, error) {
+ var doc struct {
+ XMLName xml.Name
+ LineRate string `xml:"line-rate,attr"`
+ }
+ if err := xml.Unmarshal(data, &doc); err != nil {
+ return 0, fmt.Errorf("parsing cobertura XML: %w", err)
+ }
+ if doc.XMLName.Local != "coverage" || doc.LineRate == "" {
+ return 0, fmt.Errorf("not a cobertura report: want root")
+ }
+ rate, err := strconv.ParseFloat(doc.LineRate, 64)
+ if err != nil {
+ return 0, fmt.Errorf("parsing line-rate %q: %w", doc.LineRate, err)
+ }
+ return rate * 100, nil
+}
+
+// FormatCoverageMarkdown renders a one-line coverage summary with an
+// optional threshold verdict (min < 0 means no threshold).
+func FormatCoverageMarkdown(pct, min float64) string {
+ if min >= 0 {
+ status := "✅"
+ if pct < min {
+ status = "❌"
+ }
+ return fmt.Sprintf("%s **Coverage: %.1f%%** (threshold %.1f%%)\n", status, pct, min)
+ }
+ return fmt.Sprintf("**Coverage: %.1f%%**\n", pct)
+}
diff --git a/services/report_service_test.go b/services/report_service_test.go
new file mode 100644
index 0000000..e1a1822
--- /dev/null
+++ b/services/report_service_test.go
@@ -0,0 +1,142 @@
+package services
+
+import (
+ "math"
+ "strings"
+ "testing"
+)
+
+const junitSample = `
+
+
+
+
+ stack trace here
+
+
+
+
+
+
+
+
+ boom
+more detail
+
+
+
+`
+
+func TestParseJUnit_Counts(t *testing.T) {
+ report, err := ParseJUnit([]byte(junitSample))
+ if err != nil {
+ t.Fatalf("ParseJUnit() error = %v", err)
+ }
+ if report.Total != 4 || report.Passed != 1 || report.Failures != 1 || report.Errors != 1 || report.Skipped != 1 {
+ t.Errorf("counts = %+v, want total=4 passed=1 failures=1 errors=1 skipped=1", report)
+ }
+ if math.Abs(report.Duration-3.75) > 1e-9 {
+ t.Errorf("Duration = %v, want 3.75", report.Duration)
+ }
+ if len(report.Failed) != 2 {
+ t.Fatalf("Failed = %v, want 2 cases", report.Failed)
+ }
+ if report.Failed[0].Message != "expected 2, got 3" {
+ t.Errorf("failure message = %q", report.Failed[0].Message)
+ }
+ if report.Failed[1].Message != "boom" {
+ t.Errorf("error message = %q, want first line only", report.Failed[1].Message)
+ }
+}
+
+func TestParseJUnit_BareSuiteRoot(t *testing.T) {
+ data := ``
+ report, err := ParseJUnit([]byte(data))
+ if err != nil {
+ t.Fatalf("ParseJUnit() error = %v", err)
+ }
+ if report.Total != 1 || report.Passed != 1 {
+ t.Errorf("report = %+v, want 1 passed", report)
+ }
+}
+
+func TestParseJUnit_BadRoot(t *testing.T) {
+ if _, err := ParseJUnit([]byte(``)); err == nil {
+ t.Error("ParseJUnit() expected error for non-junit root")
+ }
+}
+
+func TestSlowestJUnitCases(t *testing.T) {
+ report, err := ParseJUnit([]byte(junitSample))
+ if err != nil {
+ t.Fatalf("ParseJUnit() error = %v", err)
+ }
+ slowest := SlowestJUnitCases(report, 2)
+ if len(slowest) != 2 || slowest[0].Name != "TestErr" || slowest[1].Name != "TestBroken" {
+ t.Errorf("SlowestJUnitCases() = %v", slowest)
+ }
+}
+
+func TestFormatJUnitMarkdown(t *testing.T) {
+ report, err := ParseJUnit([]byte(junitSample))
+ if err != nil {
+ t.Fatalf("ParseJUnit() error = %v", err)
+ }
+ out := FormatJUnitMarkdown(report, 2)
+ for _, want := range []string{"❌", "**4 tests**", "`alpha/TestBroken`", "expected 2, got 3", "Slowest tests"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("FormatJUnitMarkdown() missing %q in:\n%s", want, out)
+ }
+ }
+
+ pass := &JUnitReport{Total: 2, Passed: 2}
+ if out := FormatJUnitMarkdown(pass, 0); !strings.Contains(out, "✅") {
+ t.Errorf("FormatJUnitMarkdown() all-pass missing ✅:\n%s", out)
+ }
+}
+
+func TestParseCoverage_Lcov(t *testing.T) {
+ lcov := "TN:\nSF:/src/a.go\nLF:10\nLH:8\nend_of_record\nSF:/src/b.go\nLF:10\nLH:6\nend_of_record\n"
+ pct, err := ParseCoverage([]byte(lcov), "auto")
+ if err != nil {
+ t.Fatalf("ParseCoverage() error = %v", err)
+ }
+ if math.Abs(pct-70.0) > 1e-9 {
+ t.Errorf("ParseCoverage() = %v, want 70", pct)
+ }
+}
+
+func TestParseCoverage_Cobertura(t *testing.T) {
+ xmlData := ``
+ pct, err := ParseCoverage([]byte(xmlData), "auto")
+ if err != nil {
+ t.Fatalf("ParseCoverage() error = %v", err)
+ }
+ if math.Abs(pct-84.5) > 1e-9 {
+ t.Errorf("ParseCoverage() = %v, want 84.5", pct)
+ }
+}
+
+func TestParseCoverage_Errors(t *testing.T) {
+ if _, err := ParseCoverage([]byte("not a report"), "auto"); err == nil {
+ t.Error("expected error for garbage input")
+ }
+ if _, err := ParseCoverage([]byte(``), "auto"); err == nil {
+ t.Error("expected error for non-cobertura XML")
+ }
+ if _, err := ParseCoverage([]byte(""), "nope"); err == nil {
+ t.Error("expected error for unknown format")
+ }
+}
+
+func TestFormatCoverageMarkdown(t *testing.T) {
+ if out := FormatCoverageMarkdown(75.0, 80.0); !strings.Contains(out, "❌") {
+ t.Errorf("below threshold should be ❌: %s", out)
+ }
+ if out := FormatCoverageMarkdown(85.0, 80.0); !strings.Contains(out, "✅") {
+ t.Errorf("above threshold should be ✅: %s", out)
+ }
+ if out := FormatCoverageMarkdown(85.0, -1); strings.Contains(out, "threshold") {
+ t.Errorf("no threshold should omit verdict: %s", out)
+ }
+}
diff --git a/services/structdiff_service.go b/services/structdiff_service.go
new file mode 100644
index 0000000..588ee3b
--- /dev/null
+++ b/services/structdiff_service.go
@@ -0,0 +1,172 @@
+package services
+
+import (
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+)
+
+// DiffEntry describes a single structural difference between two documents.
+type DiffEntry struct {
+ Path string `json:"path"`
+ Kind string `json:"kind"` // added, removed, changed
+ Old interface{} `json:"old,omitempty"`
+ New interface{} `json:"new,omitempty"`
+}
+
+// StructDiff compares two decoded documents structurally — key order and
+// formatting play no role — and returns the differences sorted by path.
+// ignorePaths entries prune the comparison: any difference at or below an
+// ignored path is dropped (prefix match on the jq-style path).
+func StructDiff(a, b interface{}, ignorePaths []string) []DiffEntry {
+ var entries []DiffEntry
+ walkDiff(normalizeMaps(a), normalizeMaps(b), ".", &entries)
+
+ if len(ignorePaths) > 0 {
+ kept := entries[:0]
+ for _, e := range entries {
+ if !pathIgnored(e.Path, ignorePaths) {
+ kept = append(kept, e)
+ }
+ }
+ entries = kept
+ }
+
+ sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path })
+ return entries
+}
+
+func pathIgnored(path string, ignore []string) bool {
+ for _, ig := range ignore {
+ ig = strings.TrimSuffix(ig, ".")
+ if !strings.HasPrefix(ig, ".") {
+ ig = "." + ig
+ }
+ if path == ig || strings.HasPrefix(path, ig+".") || strings.HasPrefix(path, ig+"[") {
+ return true
+ }
+ }
+ return false
+}
+
+func walkDiff(a, b interface{}, path string, out *[]DiffEntry) {
+ am, aIsMap := a.(map[string]interface{})
+ bm, bIsMap := b.(map[string]interface{})
+ if aIsMap && bIsMap {
+ keys := map[string]struct{}{}
+ for k := range am {
+ keys[k] = struct{}{}
+ }
+ for k := range bm {
+ keys[k] = struct{}{}
+ }
+ for k := range keys {
+ childPath := joinDiffPath(path, k)
+ av, aok := am[k]
+ bv, bok := bm[k]
+ switch {
+ case !aok:
+ *out = append(*out, DiffEntry{Path: childPath, Kind: "added", New: bv})
+ case !bok:
+ *out = append(*out, DiffEntry{Path: childPath, Kind: "removed", Old: av})
+ default:
+ walkDiff(av, bv, childPath, out)
+ }
+ }
+ return
+ }
+
+ as, aIsSlice := a.([]interface{})
+ bs, bIsSlice := b.([]interface{})
+ if aIsSlice && bIsSlice {
+ max := len(as)
+ if len(bs) > max {
+ max = len(bs)
+ }
+ for i := 0; i < max; i++ {
+ childPath := fmt.Sprintf("%s[%d]", strings.TrimSuffix(path, "."), i)
+ switch {
+ case i >= len(as):
+ *out = append(*out, DiffEntry{Path: childPath, Kind: "added", New: bs[i]})
+ case i >= len(bs):
+ *out = append(*out, DiffEntry{Path: childPath, Kind: "removed", Old: as[i]})
+ default:
+ walkDiff(as[i], bs[i], childPath, out)
+ }
+ }
+ return
+ }
+
+ if !jsonEqual(a, b) {
+ *out = append(*out, DiffEntry{Path: strings.TrimSuffix(path, "."), Kind: "changed", Old: a, New: b})
+ }
+}
+
+func joinDiffPath(base, key string) string {
+ if base == "." {
+ return "." + key
+ }
+ return base + "." + key
+}
+
+// FormatDiffText renders entries in a compact +/-/~ line format.
+func FormatDiffText(entries []DiffEntry) string {
+ var b strings.Builder
+ for _, e := range entries {
+ switch e.Kind {
+ case "added":
+ fmt.Fprintf(&b, "+ %s: %s\n", e.Path, compactJSON(e.New))
+ case "removed":
+ fmt.Fprintf(&b, "- %s: %s\n", e.Path, compactJSON(e.Old))
+ default:
+ fmt.Fprintf(&b, "~ %s: %s -> %s\n", e.Path, compactJSON(e.Old), compactJSON(e.New))
+ }
+ }
+ return b.String()
+}
+
+// FormatDiffMarkdown renders entries as a GitHub-flavored markdown table,
+// ready for a step summary or PR comment.
+func FormatDiffMarkdown(entries []DiffEntry) string {
+ if len(entries) == 0 {
+ return "No differences.\n"
+ }
+ var b strings.Builder
+ b.WriteString("| Path | Change | Old | New |\n|---|---|---|---|\n")
+ for _, e := range entries {
+ old, new := "", ""
+ if e.Kind != "added" {
+ old = "`" + escapeMarkdownCell(compactJSON(e.Old)) + "`"
+ }
+ if e.Kind != "removed" {
+ new = "`" + escapeMarkdownCell(compactJSON(e.New)) + "`"
+ }
+ fmt.Fprintf(&b, "| `%s` | %s | %s | %s |\n", escapeMarkdownCell(e.Path), e.Kind, old, new)
+ }
+ return b.String()
+}
+
+// FormatDiffJSON renders entries as a JSON array.
+func FormatDiffJSON(entries []DiffEntry) (string, error) {
+ if entries == nil {
+ entries = []DiffEntry{}
+ }
+ data, err := json.Marshal(entries)
+ if err != nil {
+ return "", err
+ }
+ return string(data) + "\n", nil
+}
+
+func compactJSON(v interface{}) string {
+ data, err := json.Marshal(v)
+ if err != nil {
+ return fmt.Sprintf("%v", v)
+ }
+ return string(data)
+}
+
+func escapeMarkdownCell(s string) string {
+ return strings.ReplaceAll(s, "|", "\\|")
+}
diff --git a/services/structdiff_service_test.go b/services/structdiff_service_test.go
new file mode 100644
index 0000000..1eb59ad
--- /dev/null
+++ b/services/structdiff_service_test.go
@@ -0,0 +1,127 @@
+package services
+
+import (
+ "strings"
+ "testing"
+)
+
+func decodeForTest(t *testing.T, data string, format DataFormat) interface{} {
+ t.Helper()
+ doc, err := Decode([]byte(data), format)
+ if err != nil {
+ t.Fatalf("Decode() error = %v", err)
+ }
+ return doc
+}
+
+func TestStructDiff_NoDifferences(t *testing.T) {
+ a := decodeForTest(t, `{"b": 2, "a": 1}`, FormatJSON)
+ b := decodeForTest(t, "a: 1\nb: 2\n", FormatYAML)
+ entries := StructDiff(a, b, nil)
+ if len(entries) != 0 {
+ t.Errorf("StructDiff() = %v, want empty", entries)
+ }
+}
+
+func TestStructDiff_ChangedAddedRemoved(t *testing.T) {
+ a := decodeForTest(t, `{"replicas": 2, "image": "app:v1", "old": true}`, FormatJSON)
+ b := decodeForTest(t, `{"replicas": 3, "image": "app:v1", "fresh": "yes"}`, FormatJSON)
+ entries := StructDiff(a, b, nil)
+ if len(entries) != 3 {
+ t.Fatalf("StructDiff() returned %d entries, want 3: %v", len(entries), entries)
+ }
+ byPath := map[string]DiffEntry{}
+ for _, e := range entries {
+ byPath[e.Path] = e
+ }
+ if e := byPath[".replicas"]; e.Kind != "changed" {
+ t.Errorf(".replicas kind = %q, want changed", e.Kind)
+ }
+ if e := byPath[".fresh"]; e.Kind != "added" {
+ t.Errorf(".fresh kind = %q, want added", e.Kind)
+ }
+ if e := byPath[".old"]; e.Kind != "removed" {
+ t.Errorf(".old kind = %q, want removed", e.Kind)
+ }
+}
+
+func TestStructDiff_NestedAndArrays(t *testing.T) {
+ a := decodeForTest(t, `{"spec": {"env": [{"name": "A", "value": "1"}, {"name": "B"}]}}`, FormatJSON)
+ b := decodeForTest(t, `{"spec": {"env": [{"name": "A", "value": "2"}]}}`, FormatJSON)
+ entries := StructDiff(a, b, nil)
+ if len(entries) != 2 {
+ t.Fatalf("StructDiff() returned %d entries, want 2: %v", len(entries), entries)
+ }
+ if entries[0].Path != ".spec.env[0].value" || entries[0].Kind != "changed" {
+ t.Errorf("entry[0] = %+v, want changed .spec.env[0].value", entries[0])
+ }
+ if entries[1].Path != ".spec.env[1]" || entries[1].Kind != "removed" {
+ t.Errorf("entry[1] = %+v, want removed .spec.env[1]", entries[1])
+ }
+}
+
+func TestStructDiff_TypeChange(t *testing.T) {
+ a := decodeForTest(t, `{"port": "8080"}`, FormatJSON)
+ b := decodeForTest(t, `{"port": 8080}`, FormatJSON)
+ entries := StructDiff(a, b, nil)
+ if len(entries) != 1 || entries[0].Kind != "changed" {
+ t.Fatalf("StructDiff() = %v, want one changed entry", entries)
+ }
+}
+
+func TestStructDiff_IgnorePaths(t *testing.T) {
+ a := decodeForTest(t, `{"metadata": {"generation": 1}, "spec": {"replicas": 2}}`, FormatJSON)
+ b := decodeForTest(t, `{"metadata": {"generation": 5}, "spec": {"replicas": 3}}`, FormatJSON)
+
+ entries := StructDiff(a, b, []string{".metadata"})
+ if len(entries) != 1 {
+ t.Fatalf("StructDiff() with ignore returned %d entries, want 1: %v", len(entries), entries)
+ }
+ if entries[0].Path != ".spec.replicas" {
+ t.Errorf("remaining path = %q, want .spec.replicas", entries[0].Path)
+ }
+
+ // Ignore without leading dot should behave identically.
+ entries = StructDiff(a, b, []string{"metadata.generation"})
+ if len(entries) != 1 || entries[0].Path != ".spec.replicas" {
+ t.Errorf("StructDiff() with dotless ignore = %v, want only .spec.replicas", entries)
+ }
+}
+
+func TestFormatDiffText(t *testing.T) {
+ entries := []DiffEntry{
+ {Path: ".a", Kind: "changed", Old: 1.0, New: 2.0},
+ {Path: ".b", Kind: "added", New: "x"},
+ {Path: ".c", Kind: "removed", Old: true},
+ }
+ out := FormatDiffText(entries)
+ for _, want := range []string{"~ .a: 1 -> 2", "+ .b: \"x\"", "- .c: true"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("FormatDiffText() missing %q in:\n%s", want, out)
+ }
+ }
+}
+
+func TestFormatDiffMarkdown(t *testing.T) {
+ entries := []DiffEntry{{Path: ".image", Kind: "changed", Old: "app:v1", New: "app:v2"}}
+ out := FormatDiffMarkdown(entries)
+ if !strings.Contains(out, "| Path | Change | Old | New |") {
+ t.Errorf("FormatDiffMarkdown() missing header:\n%s", out)
+ }
+ if !strings.Contains(out, "| `.image` | changed | `\"app:v1\"` | `\"app:v2\"` |") {
+ t.Errorf("FormatDiffMarkdown() missing row:\n%s", out)
+ }
+ if got := FormatDiffMarkdown(nil); got != "No differences.\n" {
+ t.Errorf("FormatDiffMarkdown(nil) = %q", got)
+ }
+}
+
+func TestFormatDiffJSON_EmptyIsArray(t *testing.T) {
+ out, err := FormatDiffJSON(nil)
+ if err != nil {
+ t.Fatalf("FormatDiffJSON() error = %v", err)
+ }
+ if strings.TrimSpace(out) != "[]" {
+ t.Errorf("FormatDiffJSON(nil) = %q, want []", out)
+ }
+}
diff --git a/services/version_service.go b/services/version_service.go
index 2f036c2..5da8d54 100644
--- a/services/version_service.go
+++ b/services/version_service.go
@@ -76,7 +76,7 @@ func VersionBump(source, bumpType, preRelease, buildMeta string) (string, error)
if err != nil {
return "", err
}
- if err := os.WriteFile(source, []byte(newContent), 0644); err != nil {
+ if err := os.WriteFile(source, []byte(newContent), 0o644); err != nil {
return "", fmt.Errorf("writing %s: %w", source, err)
}
@@ -104,7 +104,7 @@ func VersionSet(source, newVersion string) error {
if err != nil {
return err
}
- return os.WriteFile(source, []byte(newContent), 0644)
+ return os.WriteFile(source, []byte(newContent), 0o644)
}
// replaceVersionInFile rewrites only the version captured by the file's
@@ -173,33 +173,31 @@ func VersionNext(source string) (string, error) {
return "", fmt.Errorf("parsing version %q: %w", currentStr, err)
}
- // Get commits since last tag
- out, err := exec.Command("git", "log", "--oneline", "--no-decorate", fmt.Sprintf("v%s..HEAD", currentStr)).Output()
+ // Get commit subjects since the last release tag.
+ revRange := fmt.Sprintf("v%s..HEAD", currentStr)
+ out, err := exec.Command("git", "log", "--format=%s", revRange).Output()
if err != nil {
// Try without v prefix
- out, _ = exec.Command("git", "log", "--oneline", "--no-decorate", fmt.Sprintf("%s..HEAD", currentStr)).Output()
+ revRange = fmt.Sprintf("%s..HEAD", currentStr)
+ out, _ = exec.Command("git", "log", "--format=%s", revRange).Output()
}
- commits := strings.TrimSpace(string(out))
- if commits == "" {
+ subjectsRaw := strings.TrimSpace(string(out))
+ if subjectsRaw == "" {
return currentStr, nil
}
+ subjects := strings.Split(subjectsRaw, "\n")
- // Analyze conventional commits
- hasBreaking := strings.Contains(commits, "BREAKING CHANGE") || strings.Contains(commits, "!:")
- hasFeat := false
- for _, line := range strings.Split(commits, "\n") {
- if strings.Contains(line, "feat:") || strings.Contains(line, "feat(") {
- hasFeat = true
- }
- }
+ // BREAKING CHANGE footers live in commit bodies, which --format=%s hides.
+ bodies, _ := exec.Command("git", "log", "--format=%b", revRange).Output()
var next semver.Version
- if hasBreaking {
+ switch AnalyzeConventionalCommits(subjects, string(bodies)) {
+ case "major":
next = current.IncMajor()
- } else if hasFeat {
+ case "minor":
next = current.IncMinor()
- } else {
+ default:
next = current.IncPatch()
}
@@ -234,16 +232,16 @@ func FormatVersion(version, format string) string {
}
var versionPatterns = map[string]*regexp.Regexp{
- "package.json": regexp.MustCompile(`"version"\s*:\s*"([^"]+)"`),
- "Cargo.toml": regexp.MustCompile(`(?m)^version\s*=\s*"([^"]+)"`),
- "pyproject.toml": regexp.MustCompile(`(?m)^version\s*=\s*"([^"]+)"`),
- "go.mod": regexp.MustCompile(`(?m)^// version:\s*(.+)$`),
- "VERSION": regexp.MustCompile(`(?m)^(.+)$`),
- "version.txt": regexp.MustCompile(`(?m)^(.+)$`),
- "Chart.yaml": regexp.MustCompile(`(?m)^version:\s*(.+)$`),
- "setup.py": regexp.MustCompile(`version\s*=\s*['"]([^'"]+)['"]`),
- "build.gradle": regexp.MustCompile(`(?m)^version\s*=\s*['"]([^'"]+)['"]`),
- "pom.xml": regexp.MustCompile(`([^<]+)`),
+ "package.json": regexp.MustCompile(`"version"\s*:\s*"([^"]+)"`),
+ "Cargo.toml": regexp.MustCompile(`(?m)^version\s*=\s*"([^"]+)"`),
+ "pyproject.toml": regexp.MustCompile(`(?m)^version\s*=\s*"([^"]+)"`),
+ "go.mod": regexp.MustCompile(`(?m)^// version:\s*(.+)$`),
+ "VERSION": regexp.MustCompile(`(?m)^(.+)$`),
+ "version.txt": regexp.MustCompile(`(?m)^(.+)$`),
+ "Chart.yaml": regexp.MustCompile(`(?m)^version:\s*(.+)$`),
+ "setup.py": regexp.MustCompile(`version\s*=\s*['"]([^'"]+)['"]`),
+ "build.gradle": regexp.MustCompile(`(?m)^version\s*=\s*['"]([^'"]+)['"]`),
+ "pom.xml": regexp.MustCompile(`([^<]+)`),
}
func extractVersion(filename, content string) (string, error) {
@@ -290,3 +288,60 @@ func autoDetectVersionFile() string {
}
return ""
}
+
+// AssertVersionsMatch extracts the version from each file and fails unless
+// they are all identical — a monorepo guard against half-bumped releases.
+func AssertVersionsMatch(files []string) (string, error) {
+ if len(files) < 2 {
+ return "", fmt.Errorf("at least two files required")
+ }
+ first := ""
+ for i, f := range files {
+ v, err := VersionGet(f)
+ if err != nil {
+ return "", err
+ }
+ if i == 0 {
+ first = v
+ continue
+ }
+ if v != first {
+ return "", fmt.Errorf("version mismatch: %s has %s, %s has %s", files[0], first, f, v)
+ }
+ }
+ return first, nil
+}
+
+// conventionalSubject matches "type(scope)!: subject" conventional-commit
+// subjects, anchored to the line start.
+var conventionalSubject = regexp.MustCompile(`^(\w+)(\([^)]*\))?(!)?:`)
+
+// AnalyzeConventionalCommits inspects commit subjects (and full bodies for
+// BREAKING CHANGE footers) and returns the bump they imply: major, minor,
+// patch, or none.
+func AnalyzeConventionalCommits(subjects []string, bodies string) string {
+ if strings.Contains(bodies, "BREAKING CHANGE:") || strings.Contains(bodies, "BREAKING-CHANGE:") {
+ return "major"
+ }
+ bump := "none"
+ for _, subject := range subjects {
+ m := conventionalSubject.FindStringSubmatch(strings.TrimSpace(subject))
+ if m == nil {
+ continue
+ }
+ if m[3] == "!" {
+ return "major"
+ }
+ switch strings.ToLower(m[1]) {
+ case "feat":
+ if bump != "major" {
+ bump = "minor"
+ }
+ case "fix", "perf", "refactor", "revert":
+ if bump == "none" {
+ bump = "patch"
+ }
+ }
+ }
+ return bump
+}
diff --git a/services/version_service_test.go b/services/version_service_test.go
index 1cbe696..e6823a1 100644
--- a/services/version_service_test.go
+++ b/services/version_service_test.go
@@ -9,7 +9,7 @@ import (
func TestVersionGet_PackageJSON(t *testing.T) {
tmpDir := t.TempDir()
f := filepath.Join(tmpDir, "package.json")
- os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0644)
+ os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0o644)
version, err := VersionGet(f)
if err != nil {
@@ -23,7 +23,7 @@ func TestVersionGet_PackageJSON(t *testing.T) {
func TestVersionGet_CargoToml(t *testing.T) {
tmpDir := t.TempDir()
f := filepath.Join(tmpDir, "Cargo.toml")
- os.WriteFile(f, []byte("[package]\nname = \"myapp\"\nversion = \"0.5.1\"\n"), 0644)
+ os.WriteFile(f, []byte("[package]\nname = \"myapp\"\nversion = \"0.5.1\"\n"), 0o644)
version, err := VersionGet(f)
if err != nil {
@@ -37,7 +37,7 @@ func TestVersionGet_CargoToml(t *testing.T) {
func TestVersionGet_VersionFile(t *testing.T) {
tmpDir := t.TempDir()
f := filepath.Join(tmpDir, "VERSION")
- os.WriteFile(f, []byte("3.0.0\n"), 0644)
+ os.WriteFile(f, []byte("3.0.0\n"), 0o644)
version, err := VersionGet(f)
if err != nil {
@@ -51,7 +51,7 @@ func TestVersionGet_VersionFile(t *testing.T) {
func TestVersionBump_Patch(t *testing.T) {
tmpDir := t.TempDir()
f := filepath.Join(tmpDir, "package.json")
- os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0644)
+ os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0o644)
newVersion, err := VersionBump(f, "patch", "", "")
if err != nil {
@@ -71,7 +71,7 @@ func TestVersionBump_Patch(t *testing.T) {
func TestVersionBump_Minor(t *testing.T) {
tmpDir := t.TempDir()
f := filepath.Join(tmpDir, "package.json")
- os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0644)
+ os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0o644)
newVersion, err := VersionBump(f, "minor", "", "")
if err != nil {
@@ -85,7 +85,7 @@ func TestVersionBump_Minor(t *testing.T) {
func TestVersionBump_Major(t *testing.T) {
tmpDir := t.TempDir()
f := filepath.Join(tmpDir, "package.json")
- os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0644)
+ os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0o644)
newVersion, err := VersionBump(f, "major", "", "")
if err != nil {
@@ -99,7 +99,7 @@ func TestVersionBump_Major(t *testing.T) {
func TestVersionBump_WithPreRelease(t *testing.T) {
tmpDir := t.TempDir()
f := filepath.Join(tmpDir, "package.json")
- os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0644)
+ os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0o644)
newVersion, err := VersionBump(f, "patch", "alpha.1", "")
if err != nil {
@@ -161,7 +161,7 @@ func TestVersionBump_DoesNotRewriteDependencyPin(t *testing.T) {
"dependencies": { "react": "1.2.3" },
"version": "1.2.3"
}`
- os.WriteFile(f, []byte(original), 0644)
+ os.WriteFile(f, []byte(original), 0o644)
newVersion, err := VersionBump(f, "patch", "", "")
if err != nil {
@@ -183,7 +183,7 @@ func TestVersionBump_DoesNotRewriteDependencyPin(t *testing.T) {
func TestVersionSet(t *testing.T) {
tmpDir := t.TempDir()
f := filepath.Join(tmpDir, "Cargo.toml")
- os.WriteFile(f, []byte("[package]\nname = \"myapp\"\nversion = \"0.5.1\"\n"), 0644)
+ os.WriteFile(f, []byte("[package]\nname = \"myapp\"\nversion = \"0.5.1\"\n"), 0o644)
if err := VersionSet(f, "1.0.0"); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -206,3 +206,55 @@ func containsHelper(s, substr string) bool {
}
return false
}
+
+func TestAssertVersionsMatch(t *testing.T) {
+ dir := t.TempDir()
+ pkg := filepath.Join(dir, "package.json")
+ chart := filepath.Join(dir, "Chart.yaml")
+ os.WriteFile(pkg, []byte(`{"name": "app", "version": "1.2.3"}`), 0o644)
+ os.WriteFile(chart, []byte("apiVersion: v2\nname: app\nversion: 1.2.3\n"), 0o644)
+
+ version, err := AssertVersionsMatch([]string{pkg, chart})
+ if err != nil {
+ t.Fatalf("AssertVersionsMatch() error = %v", err)
+ }
+ if version != "1.2.3" {
+ t.Errorf("version = %q, want 1.2.3", version)
+ }
+
+ os.WriteFile(chart, []byte("apiVersion: v2\nname: app\nversion: 1.3.0\n"), 0o644)
+ if _, err := AssertVersionsMatch([]string{pkg, chart}); err == nil {
+ t.Error("AssertVersionsMatch() should fail on mismatch")
+ }
+
+ if _, err := AssertVersionsMatch([]string{pkg}); err == nil {
+ t.Error("AssertVersionsMatch() should require at least two files")
+ }
+}
+
+func TestAnalyzeConventionalCommits(t *testing.T) {
+ tests := []struct {
+ name string
+ subjects []string
+ bodies string
+ want string
+ }{
+ {"feature", []string{"feat: add pagination"}, "", "minor"},
+ {"scoped feature", []string{"feat(http): add pagination"}, "", "minor"},
+ {"fix only", []string{"fix: handle nil", "docs: readme"}, "", "patch"},
+ {"breaking bang", []string{"feat(api)!: drop v1"}, "", "major"},
+ {"breaking footer", []string{"fix: tweak"}, "BREAKING CHANGE: renamed flags", "major"},
+ {"breaking hyphen footer", []string{"chore: deps"}, "BREAKING-CHANGE: removed cmd", "major"},
+ {"no conventional commits", []string{"updated stuff"}, "", "none"},
+ {"feat mentioned mid-subject is not a feat", []string{"docs: describe feat: syntax"}, "", "none"},
+ {"bang mid-subject is not breaking", []string{"fix: handle msg ending in !: really"}, "", "patch"},
+ {"perf counts as patch", []string{"perf: faster parse"}, "", "patch"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := AnalyzeConventionalCommits(tt.subjects, tt.bodies); got != tt.want {
+ t.Errorf("AnalyzeConventionalCommits(%v) = %q, want %q", tt.subjects, got, tt.want)
+ }
+ })
+ }
+}