feat(build): fuzz testing and git-cliff changelog automation
Add FuzzParseBytes and FuzzSanitizeYAMLEscapes in internal/model/fuzz_test.go. Both targets run as regular seed-based tests in CI (go test ./...) and can be run continuously via `task fuzz` (default 30 s per target; FUZZ_TIME=60s to extend). Fuzz corpus failures are saved to testdata/fuzz/ for regression. Add cliff.toml configuring git-cliff to generate Keep-a-Changelog-compatible release notes from Conventional Commits. New tasks: `task changelog` (regenerate full CHANGELOG.md) and `task changelog-next` (preview unreleased entries without writing). Requires git-cliff (brew/cargo install git-cliff). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
|
||||
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.26-blue.svg" alt="Release"></a>
|
||||
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.27-blue.svg" alt="Release"></a>
|
||||
</p>
|
||||
|
||||
> **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-<tag>.exe)
|
||||
task build-linux # cross-compile for Linux x64 (requires a tagged commit → glint-<tag>-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
|
||||
|
||||
```
|
||||
|
||||
+2
-2
@@ -119,5 +119,5 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
|
||||
- ~~**`glint explain <rule-id>`**~~ — ✓ 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+65
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user