diff --git a/CHANGELOG.md b/CHANGELOG.md
index f26b75a..7638b9d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org).
+## [0.2.27] - 2026-06-25
+
+### Added
+
+- **Fuzz testing** — `FuzzParseBytes` and `FuzzSanitizeYAMLEscapes` in `internal/model/fuzz_test.go` verify that neither the YAML parser nor the escape sanitizer panics on arbitrary input. Successful parses are also checked for structural integrity (non-nil pipeline, no empty job names). Run with `task fuzz` (default 30 s per target; set `FUZZ_TIME=60s` to extend). Found failures are saved to `testdata/fuzz/` for regression.
+
+- **Changelog automation** — `cliff.toml` configures [git-cliff](https://git-cliff.org) to generate Keep-a-Changelog–compatible release notes from Conventional Commits. `task changelog` regenerates `CHANGELOG.md` from the full git history; `task changelog-next` previews only unreleased commits without writing. Install git-cliff with `brew install git-cliff` or `cargo install git-cliff`.
+
## [0.2.26] - 2026-06-25
### Changed
diff --git a/README.md b/README.md
index 7147c9f..ceb0710 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
@@ -69,11 +69,18 @@ task test # run Go unit tests
task lint-go # run go vet
task validate # run the binary against all testdata fixtures
task ci # full check: vet → test → build → validate
+task fuzz # run fuzz tests for the YAML parser (Ctrl-C to stop; FUZZ_TIME=60s to set duration)
+task changelog # regenerate CHANGELOG.md from git history via git-cliff
+task changelog-next # preview unreleased section (dry-run, no file written)
task build-windows # cross-compile for Windows x64 (requires a tagged commit → glint-.exe)
task build-linux # cross-compile for Linux x64 (requires a tagged commit → glint--linux-amd64)
task clean # remove build artifacts
```
+**Optional tools:**
+
+- [git-cliff](https://git-cliff.org) — changelog generator used by `task changelog`. Install with `brew install git-cliff` or `cargo install git-cliff`.
+
## Project structure
```
diff --git a/ROADMAP.md b/ROADMAP.md
index e0a88b6..ad9a49f 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -119,5 +119,5 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
- ~~**`glint explain `**~~ — ✓ shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
-- **Changelog automation** — generate release notes from Conventional Commits via `git-cliff` or similar
-- **Fuzz testing** — add a `go test -fuzz` target for the YAML parser to harden it against malformed input
+- ~~**Changelog automation**~~ — ✓ shipped v0.2.27; `cliff.toml` configures git-cliff to produce Keep-a-Changelog–compatible release notes from Conventional Commits; `task changelog` regenerates `CHANGELOG.md`, `task changelog-next` previews unreleased entries
+- ~~**Fuzz testing**~~ — ✓ shipped v0.2.27; `FuzzParseBytes` and `FuzzSanitizeYAMLEscapes` in `internal/model/fuzz_test.go`; seeds run as regular tests in CI; `task fuzz` runs them continuously (default 30 s)
diff --git a/Taskfile.yml b/Taskfile.yml
index deba163..f023692 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -175,6 +175,20 @@ tasks:
generates:
- "{{.BINARY}}-{{.TAG}}-linux-amd64"
+ fuzz:
+ desc: "Run fuzz tests for the YAML parser and sanitizer (set FUZZ_TIME=60s to control duration, default 30s)"
+ cmds:
+ - "{{.GO}} test -fuzz=FuzzParseBytes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/"
+ - "{{.GO}} test -fuzz=FuzzSanitizeYAMLEscapes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/"
+
+ changelog:
+ desc: "Regenerate CHANGELOG.md from git history (requires git-cliff — see README)"
+ cmd: git cliff --config cliff.toml --output CHANGELOG.md
+
+ changelog-next:
+ desc: "Preview unreleased changelog entries without writing (requires git-cliff)"
+ cmd: git cliff --config cliff.toml --unreleased
+
clean:
desc: Remove build artifacts
cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64
diff --git a/cliff.toml b/cliff.toml
new file mode 100644
index 0000000..7dd14cd
--- /dev/null
+++ b/cliff.toml
@@ -0,0 +1,65 @@
+# git-cliff configuration for glint
+# Install: brew install git-cliff OR cargo install git-cliff
+# Usage: task changelog -- regenerate full CHANGELOG.md
+# task changelog-next -- preview unreleased section (dry-run)
+
+[changelog]
+header = """
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+This project uses [Semantic Versioning](https://semver.org).
+"""
+body = """
+{% if version %}\
+## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
+
+{% else %}\
+## [Unreleased]
+
+{% endif %}\
+{% for group, commits in commits | group_by(attribute="group") %}\
+### {{ group | upper_first }}
+
+{% for commit in commits %}\
+- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
+{{ commit.message | upper_first }}\
+{% if commit.breaking %} **[BREAKING]**{% endif %}
+
+{% endfor %}\
+{% endfor %}\
+"""
+trim = true
+footer = ""
+postprocessors = []
+
+[git]
+conventional_commits = true
+filter_unconventional = true
+split_commits = false
+commit_preprocessors = [
+ # Drop Co-Authored-By trailers (should not appear in subjects, but guard anyway).
+ { pattern = "Co-Authored-By:.*", replace = "" },
+]
+commit_parsers = [
+ # Breaking changes (type! or scope!) — promote above everything else.
+ { message = "^[a-z]+\\([a-z-]+\\)!:|^[a-z]+!:", group = "Breaking Changes" },
+ { message = "^feat", group = "Added" },
+ { message = "^fix", group = "Fixed" },
+ { message = "^perf", group = "Changed" },
+ { message = "^refactor", group = "Changed" },
+ # Maintenance commits — omit from the changelog body.
+ { message = "^docs", skip = true },
+ { message = "^style", skip = true },
+ { message = "^test", skip = true },
+ { message = "^chore", skip = true },
+ { message = "^build", skip = true },
+ { message = "^claude", skip = true },
+]
+protect_breaking_commits = false
+filter_commits = true
+tag_pattern = "v[0-9].*"
+topo_order = false
+sort_commits = "oldest"
diff --git a/internal/model/fuzz_test.go b/internal/model/fuzz_test.go
new file mode 100644
index 0000000..d60ad93
--- /dev/null
+++ b/internal/model/fuzz_test.go
@@ -0,0 +1,77 @@
+package model
+
+import "testing"
+
+// FuzzParseBytes ensures the YAML parser never panics on arbitrary input and
+// that successful parses return a structurally sound Pipeline.
+// Run with: go test -fuzz=FuzzParseBytes ./internal/model/
+// Found failures are saved to testdata/fuzz/FuzzParseBytes/.
+func FuzzParseBytes(f *testing.F) {
+ // Seed corpus: representative inputs covering the main code paths in
+ // ParseBytes, including the sanitizeYAMLEscapes pre-processing step.
+ seeds := [][]byte{
+ {},
+ []byte("null"),
+ []byte("stages: [build]\nbuild-job:\n stage: build\n script: echo ok\n"),
+ []byte(".base:\n script: [make]\nchild:\n extends: .base\n stage: test\n"),
+ []byte("stages: [a, b]\njob-a:\n stage: a\n script: run\njob-b:\n stage: b\n needs: [job-a]\n script: run\n"),
+ []byte("*undefined_anchor"),
+ []byte("- item1\n- item2\n"),
+ []byte("my-job: \"just a string\"\n"),
+ []byte("my-job:\n stage: [build, test]\n"),
+ []byte("# glint: ignore GL007\nlegacy:\n only: [main]\n script: ok\n"),
+ []byte("workflow:\n rules:\n - if: '$CI_COMMIT_BRANCH == \"main\"'\n when: always\njob:\n script: echo ok\n"),
+ []byte("include:\n - local: other.yml\njob:\n script: echo ok\n"),
+ []byte("job:\n script: echo ok\n when: on_failure\n rules:\n - if: '$VAR =~ /^us\\//'\n"),
+ []byte("job:\n stage: test\n image:\n name: golang:1.21\n entrypoint: ['']\n parallel:\n matrix:\n - PLATFORM: [linux, darwin]\n script: go build\n"),
+ []byte("default:\n retry: 2\n timeout: 1h30m\nvariables:\n ENV: production\nstages: [build, test, deploy]\n"),
+ []byte("&anchor\n script: [echo ok]\njob:\n <<: *anchor\n stage: build\n"),
+ }
+ for _, s := range seeds {
+ f.Add(s)
+ }
+
+ f.Fuzz(func(t *testing.T, data []byte) {
+ p, err := ParseBytes(data)
+ if err != nil {
+ return // errors are acceptable; panics are not
+ }
+ if p == nil {
+ t.Fatal("ParseBytes returned nil pipeline with nil error")
+ }
+ for name := range p.Jobs {
+ if name == "" {
+ t.Fatal("ParseBytes produced a job with an empty name")
+ }
+ }
+ })
+}
+
+// FuzzSanitizeYAMLEscapes ensures the escape sanitizer never panics and never
+// produces output shorter than its input (it can only expand \/ to \\/).
+func FuzzSanitizeYAMLEscapes(f *testing.F) {
+ seeds := [][]byte{
+ {},
+ []byte("stage: build"),
+ []byte(`if: "$CI_BRANCH =~ /^us\//"`),
+ []byte(`"pattern: /^us\//"`),
+ []byte(`'single quoted \/ unchanged'`),
+ []byte(`"\n\t\r"`),
+ []byte(`"nested \"quote\" inside"`),
+ []byte(`'it''s fine'`),
+ []byte(`"unclosed`),
+ {'"', '\\'}, // double-quoted string ending with a lone backslash
+ {'"', '\\', '/'}, // the exact sequence being rewritten
+ }
+ for _, s := range seeds {
+ f.Add(s)
+ }
+
+ f.Fuzz(func(t *testing.T, data []byte) {
+ out := sanitizeYAMLEscapes(data)
+ if len(out) < len(data) {
+ t.Fatalf("sanitizeYAMLEscapes shrank output: input len=%d output len=%d\ninput: %q",
+ len(data), len(out), data)
+ }
+ })
+}