diff --git a/CHANGELOG.md b/CHANGELOG.md index da9a675..6a8294c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to TagLock will be documented here. +## Unreleased + +No unreleased changes yet. + +## 0.4.0 - 2026-08-14 + +### Added + +- `taglock check --format github` emits GitHub Actions workflow commands (`::error`, `::warning`, `::notice`) so pull-request files are annotated without a SARIF upload. +- `output.GitHub` for embedders that want the same annotation stream. + +### Compatibility + +- Existing analyzer, CLI, snapshot, schema, verification, and evolution APIs are unchanged. +- `text`, `json`, and `sarif` formats remain the default and previous options. +- The release is additive and requires no configuration migration. + ## 0.3.0 - 2026-08-11 ### Added diff --git a/README.md b/README.md index ad298c4..9229543 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ go install github.com/theworker02/taglock/cmd/taglock@latest taglock check ./... taglock check --format json --fail-on error ./... taglock check --format sarif ./... > taglock.sarif +taglock check --format github ./... taglock check --json-semantics v2 ./... ``` diff --git a/docs/releases/v0.4.0.md b/docs/releases/v0.4.0.md new file mode 100644 index 0000000..11f9e95 --- /dev/null +++ b/docs/releases/v0.4.0.md @@ -0,0 +1,26 @@ +# TagLock v0.4.0 — GitHub Actions annotations + +TagLock v0.4.0 adds a GitHub Actions workflow-command report format so `taglock check` can annotate pull-request files without uploading SARIF. + +## Added + +- `taglock check --format github` emits `::error`, `::warning`, and `::notice` commands with file, line, column, and rule title. +- `output.GitHub` for library embedders. + +## Compatibility + +- Existing analyzer, CLI, snapshot, schema, verification, and evolution APIs are unchanged. +- `text`, `json`, and `sarif` remain supported. The default check format is still `text`. +- Go 1.24 remains the minimum supported toolchain. + +## Upgrade + +```bash +go get github.com/theworker02/taglock@v0.4.0 +``` + +## Example + +```bash +taglock check --format github --fail-on error ./... +``` diff --git a/internal/cli/run.go b/internal/cli/run.go index 8eaeebf..7fb3dbe 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -102,7 +102,7 @@ func Run(arguments []string, stdout, stderr io.Writer) int { func runCheck(arguments []string, stdout, stderr io.Writer) int { set := newFlagSet("check", stderr) - format := set.String("format", "text", "output format: text, json, or sarif") + format := set.String("format", "text", "output format: text, json, sarif, or github") failOn := set.String("fail-on", "warning", "minimum severity that fails") configPath := set.String("config", "", "configuration path") baselinePath := set.String("baseline", "", "baseline file") @@ -115,7 +115,7 @@ func runCheck(arguments []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "taglock:", err) return ExitUsage } - if *format != "text" && *format != "json" && *format != "sarif" { + if *format != "text" && *format != "json" && *format != "sarif" && *format != "github" { fmt.Fprintf(stderr, "taglock: invalid format %q\n", *format) return ExitUsage } @@ -170,6 +170,8 @@ func runCheck(arguments []string, stdout, stderr io.Writer) int { err = output.JSON(stdout, result.FileSet, diagnostics) case "sarif": err = output.SARIF(stdout, result.FileSet, diagnostics) + case "github": + err = output.GitHub(stdout, result.FileSet, diagnostics) } if err != nil { fmt.Fprintln(stderr, "taglock: write output:", err) diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 5f5b2fe..cb12eca 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -60,6 +60,17 @@ func TestRulesAndExplainShareCatalog(t *testing.T) { } } +func TestGitHubCheckFormat(t *testing.T) { + var out, errOut bytes.Buffer + code := cli.Run([]string{"check", "--format", "github", "./testdata/violation"}, &out, &errOut) + if code != cli.ExitViolations { + t.Fatalf("check github code=%d out=%s err=%s", code, out.String(), errOut.String()) + } + if !strings.Contains(out.String(), "::error ") || !strings.Contains(out.String(), "title=") { + t.Fatalf("expected GitHub workflow commands, got %s", out.String()) + } +} + func TestVersionCommand(t *testing.T) { var out, errOut bytes.Buffer if code := cli.Run([]string{"version"}, &out, &errOut); code != cli.ExitOK { diff --git a/output/output.go b/output/output.go index e0bbfb1..edad8b8 100644 --- a/output/output.go +++ b/output/output.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/theworker02/taglock/baseline" + "github.com/theworker02/taglock/rule" "github.com/theworker02/taglock/rules" ) @@ -85,6 +86,47 @@ func JSON(writer io.Writer, fileSet *token.FileSet, diagnostics []rules.Diagnost return encoder.Encode(JSONDocument{SchemaVersion: JSONSchemaVersion, Findings: Flatten(fileSet, diagnostics)}) } +// GitHub writes GitHub Actions workflow commands so `check --format github` +// annotates pull-request files without a SARIF upload step. +func GitHub(writer io.Writer, fileSet *token.FileSet, diagnostics []rules.Diagnostic) error { + for _, diagnostic := range diagnostics { + position := fileSet.Position(diagnostic.Pos) + command := githubCommand(diagnostic.Severity) + title := githubEscapeProperty(diagnostic.Rule.ID) + file := githubEscapeProperty(position.Filename) + message := githubEscapeMessage(diagnostic.Rule.ID + " " + diagnostic.Message) + if _, err := fmt.Fprintf(writer, "::%s file=%s,line=%d,col=%d,title=%s::%s\n", command, file, position.Line, position.Column, title, message); err != nil { + return err + } + } + return nil +} + +func githubCommand(severity rule.Severity) string { + switch severity { + case rule.SeverityError: + return "error" + case rule.SeverityWarning: + return "warning" + default: + return "notice" + } +} + +func githubEscapeMessage(value string) string { + value = strings.ReplaceAll(value, "%", "%25") + value = strings.ReplaceAll(value, "\r", "%0D") + value = strings.ReplaceAll(value, "\n", "%0A") + return value +} + +func githubEscapeProperty(value string) string { + value = githubEscapeMessage(value) + value = strings.ReplaceAll(value, ":", "%3A") + value = strings.ReplaceAll(value, ",", "%2C") + return value +} + type sarifLog struct { Version string `json:"version"` Schema string `json:"$schema"` diff --git a/output/output_test.go b/output/output_test.go index c4e4f93..f965d13 100644 --- a/output/output_test.go +++ b/output/output_test.go @@ -46,3 +46,17 @@ func TestSARIFContainsRuleAndLocation(t *testing.T) { } } } + +func TestGitHubWorkflowCommands(t *testing.T) { + set, diagnostics := testDiagnostic() + var buffer bytes.Buffer + if err := output.GitHub(&buffer, set, diagnostics); err != nil { + t.Fatal(err) + } + value := buffer.String() + for _, expected := range []string{"::error file=model.go,line=2,col=", "title=TAG104::", "TAG104 duplicate json name"} { + if !strings.Contains(value, expected) { + t.Fatalf("missing %s in %s", expected, value) + } + } +}