diff --git a/.drone.yml b/.drone.yml
index 4e96d51..d6e446f 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -2,11 +2,62 @@ kind: pipeline
type: docker
name: go-lib/gelf
+trigger:
+ event:
+ - push
+ - tag
+ ref:
+ include:
+ - refs/heads/**
+ - refs/tags/v*
+
steps:
- name: test
- image: golang:1.20.1
+ image: golang:1.25.8
commands:
- go get ./...
- - go test ./...
+ - go vet ./...
+ - mkdir -p .build
+ - go test -v -coverprofile .build/coverage.out ./...
+ - go tool cover -func .build/coverage.out | tee .build/coverage.txt
+ - bash scripts/check-coverage.sh .build/coverage.out 80
- go install golang.org/x/vuln/cmd/govulncheck@latest
- - govulncheck -v ./...
\ No newline at end of file
+ - govulncheck -json ./... > .build/vulncheck.json
+
+- name: release-notes
+ image: golang:1.25.8
+ commands:
+ - bash scripts/generate-release-notes.sh
+ when:
+ event:
+ - tag
+ status:
+ - success
+
+- name: package
+ image: golang:1.25.8
+ commands:
+ - tar czf .build/sources.tar.gz --exclude=.build --exclude=.git --exclude=.drone.yml .
+ when:
+ event:
+ - tag
+ status:
+ - success
+
+- name: release
+ image: plugins/gitea-release
+ settings:
+ base_url: https://scm.yoorie.de
+ api_key:
+ from_secret: gitea_token
+ files:
+ - .build/coverage.txt
+ - .build/sources.tar.gz
+ - .build/release-notes.md
+ title: ${DRONE_TAG}
+ note_from_file: .build/release-notes.md
+ when:
+ event:
+ - tag
+ status:
+ - success
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 32fd1c9..a5418ed 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,50 +1,85 @@
# AGENTS.md
## Purpose
-This file defines the default working agreement for AI coding agents and contributors in this repository.
+
+This file defines the default working agreement for AI coding agents
+and contributors in this repository.
## Documentation Language
-Project language is English. All documentation, issues, pull requests, commit messages, and code comments should be written in English.
+
+Project language is English. All documentation, issues, pull requests,
+commit messages, and code comments should be written in English.
+
All newly created documentation in this project must be written in English.
-All project documentation files must be stored under `docs/`, except `README.md` and `AGENTS.md`.
+
+All project documentation files must be stored under `docs/`, except
+`README.md` and `AGENTS.md`.
## Working Principles
+
1. Keep changes minimal and focused on the requested task.
-2. Preserve existing public APIs unless a breaking change is explicitly requested.
+
+2. Preserve existing public APIs unless a breaking change is explicitly
+ requested.
+
3. Prefer clear, maintainable code over clever shortcuts.
+
4. Do not modify unrelated files.
+
5. Never add secrets, credentials, or tokens to the repository.
## Testing Expectations
+
1. Add or update tests for behavior changes.
+
2. Keep tests deterministic and fast.
+
3. Prefer table-driven tests where they improve readability.
+
4. Run relevant tests locally before finishing changes.
-5. For Go projects, use `github.com/smartystreets/goconvey` as the standard test library.
+
+5. For Go projects, use `github.com/smartystreets/goconvey` as the
+ standard test library.
## Build Artifacts and Reports
+
1. Builder logs and generated reports must be created under `.build/`.
+
2. The `.build/` directory must be excluded from version control via `.gitignore`.
## Git and Script Standards
+
1. Shell scripts (`*.sh`) must use LF line endings.
-2. Shell scripts committed to the repository must be executable in Git index (mode `100755`).
-3. When adding a new shell script, set execute permissions before commit: `git add --chmod=+x path/to/script.sh`.
+
+2. Shell scripts committed to the repository must be executable in Git
+ index (mode `100755`).
+
+3. When adding a new shell script, set execute permissions before
+ commit: `git add --chmod=+x path/to/script.sh`.
## Git Bash Execution Defaults
+
1. Repository maintenance scripts are executed with Git Bash shell on Windows.
+
2. Default repository root is `~/git`.
+
3. The repository root can be overridden via `GO_GIT_ROOT`.
+
4. The standards repository defaults to `$ROOT_PATH/project-standards`.
+
5. The standards repository path can be overridden via `GO_PROJECT_STANDARDS`.
## Definition of Done (DoD)
-### Purpose
-The Definition of Done defines the minimum quality bar for every completed change in this repository.
+### DoD Purpose
+
+The Definition of Done defines the minimum quality bar for every
+completed change in this repository.
### Mandatory Criteria
+
1. Tests
+
- Every code change is covered by tests where applicable.
- New functionality includes new tests.
- Bug fixes include at least one regression test.
@@ -52,37 +87,47 @@ The Definition of Done defines the minimum quality bar for every completed chang
- Automated test coverage is at least 80%.
1. Functional documentation
+
- Implemented functionality is documented.
- Public API-relevant changes are reflected in README and/or docs.
1. Documentation standards
+
- Documentation is written in English.
- Documentation files are placed under `docs/`.
- Exceptions: `README.md` and `AGENTS.md` remain at repository root.
### Technical Completion Criteria
+
1. Build and test status
+
- The project builds successfully.
- Relevant test commands run successfully.
1. No unresolved critical issues
+
- No new blocking errors are introduced.
-- Known non-blocking warnings are acceptable only if unrelated to the change or documented.
+- Known non-blocking warnings are acceptable only if unrelated to the
+ change or documented.
1. SonarQube status
+
- No SonarQube errors are present.
1. Documentation structure
+
- Links to moved or newly added docs are valid.
- Documentation structure remains consistent with project rules.
### Review Checklist (Quick)
+
- [ ] Change is implemented and meets acceptance criteria.
- [ ] Tests were added/updated and pass.
- [ ] Go tests use `github.com/smartystreets/goconvey`.
- [ ] Automated test coverage is at least 80%.
- [ ] Functionality is documented.
- [ ] Documentation is in English.
-- [ ] Documentation is located under `docs/` (except `README.md` and `AGENTS.md`).
+- [ ] Documentation is located under `docs/` (except `README.md` and
+ `AGENTS.md`).
- [ ] No SonarQube errors are present.
- [ ] No critical regressions found.
diff --git a/README.md b/README.md
index 89d09dc..94e7b69 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,102 @@
-

+# Go GELF Logging Library
-# Go graylog logging library
+
-[](https://drone.yoorie.de/go-lib/gelf)
+[![Build Status][build-badge]][build-link]
-## Documentation
+Minimal helper package to send application logs to Graylog via GELF
+while still writing to the standard Go logger.
-Is missed so far and will be created soon.
+## Overview
+
+This module wraps [gopkg.in/aphistic/golf.v0][golf] and exposes a
+small API for common log levels.
+
+Behavior summary:
+
+- If GELF is configured with `SetDefaultConfig`, messages are sent to
+ Graylog over UDP.
+- Messages are also written to the standard Go logger (`log` package).
+- If GELF is not configured, logging still works locally via the
+ standard logger.
+
+## Installation
+
+```bash
+go get scm.yoorie.de/go-lib/gelf
+```
+
+## Quick Start
+
+```go
+package main
+
+import (
+ "scm.yoorie.de/go-lib/gelf"
+)
+
+func main() {
+ gelf.SetDefaultConfig("graylog.example.local", 12201,
+ map[string]interface{}{
+ "service": "billing-api",
+ "env": "prod",
+ },
+ )
+
+ gelf.Info("service started")
+ gelf.Infof("listening on %s", ":8080")
+ gelf.Alert("database latency is high")
+}
+```
+
+## API
+
+### Configuration
+
+- `SetDefaultConfig(host string, port int, attrs map[string]interface{})`
+
+Creates and configures the default GELF logger. Attributes in
+`attrs` are attached as default fields to all GELF messages.
+
+### Logging Functions
+
+- `Debug(msg string)`
+- `Debugf(format string, va ...interface{})`
+- `Info(msg string)`
+- `Infof(format string, va ...interface{})`
+- `Alert(msg string)`
+- `Alertf(format string, va ...interface{})`
+- `Fatal(msg string)`
+- `Fatalf(format string, va ...interface{})`
+
+## Notes
+
+- `Fatal` and `Fatalf` terminate the program by calling `log.Fatalf`.
+- Graylog delivery uses UDP (`udp://host:port`).
+- Calling `SetDefaultConfig` again replaces the previous client.
+
+## Development
+
+Run tests:
+
+```bash
+go test ./...
+```
+
+Module information is defined in [go.mod](go.mod).
+
+## Additional Documentation
+
+- [Definition of Done](docs/DEFINITION_OF_DONE.md)
+- [Releasing](docs/RELEASING.md)
+
+## License
+
+See [LICENSE](LICENSE).
---
Copyright © 2023 yoorie.de
+
+[build-badge]: https://drone.yoorie.de/api/badges/go-lib/gelf/status.svg
+[build-link]: https://drone.yoorie.de/go-lib/gelf
+[golf]: https://gopkg.in/aphistic/golf.v0
diff --git a/docs/DEFINITION_OF_DONE.md b/docs/DEFINITION_OF_DONE.md
index a47d72f..9487d48 100644
--- a/docs/DEFINITION_OF_DONE.md
+++ b/docs/DEFINITION_OF_DONE.md
@@ -2,11 +2,13 @@
## Purpose
-This Definition of Done defines the minimum quality bar for every completed change in this repository.
+This Definition of Done defines the minimum quality bar for every
+completed change in this repository.
## Mandatory Criteria
1. Tests
+
- Every code change is covered by tests where applicable.
- New functionality includes new tests.
- Bug fixes include at least one regression test.
@@ -14,10 +16,12 @@ This Definition of Done defines the minimum quality bar for every completed chan
- Automated test coverage is at least 80%.
1. Functional documentation
+
- Implemented functionality is documented.
- Public API-relevant changes are reflected in README and/or docs.
1. Documentation standards
+
- Documentation is written in English.
- Documentation files are placed under `docs/`.
- Exceptions: `README.md` and `AGENTS.md` remain at repository root.
@@ -25,17 +29,22 @@ This Definition of Done defines the minimum quality bar for every completed chan
## Technical Completion Criteria
1. Build and test status
+
- The project builds successfully.
- Relevant test commands run successfully.
1. No unresolved critical issues
+
- No new blocking errors are introduced.
-- Known non-blocking warnings are acceptable only if unrelated to the change or documented.
+- Known non-blocking warnings are acceptable only if unrelated to the
+ change or documented.
1. SonarQube status
+
- No SonarQube errors are present.
1. Documentation links and structure
+
- Links to moved or newly added docs are valid.
- Documentation structure remains consistent with project rules.
@@ -47,6 +56,7 @@ This Definition of Done defines the minimum quality bar for every completed chan
- [ ] Automated test coverage is at least 80%.
- [ ] Functionality is documented.
- [ ] Documentation is in English.
-- [ ] Documentation is located under `docs/` (except `README.md` and `AGENTS.md`).
+- [ ] Documentation is located under `docs/` (except `README.md` and
+ `AGENTS.md`).
- [ ] No SonarQube errors are present.
- [ ] No critical regressions found.
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
new file mode 100644
index 0000000..bd3e45b
--- /dev/null
+++ b/docs/RELEASING.md
@@ -0,0 +1,115 @@
+# Releasing go-lib/gelf
+
+This document describes the process for creating a release of the
+`go-lib/gelf` library.
+
+## Overview
+
+Releases in this project are managed via Git tags. When you push a
+tag matching the pattern `v*` (for example, `v0.2.0`), the Drone
+pipeline automatically:
+
+1. Runs quality checks (tests, coverage gate, vet, vulnerability scan)
+2. Generates release notes from commits
+3. Creates a source archive (`sources.tar.gz`)
+4. Publishes a release to Gitea with attached artifacts
+
+## Versioning
+
+This library follows semantic versioning: `MAJOR.MINOR.PATCH`
+(for example, `v1.2.3`).
+
+- `v1.0.0`: breaking API changes
+- `v1.1.0`: backward-compatible features
+- `v1.0.1`: backward-compatible fixes
+
+## Prerequisites
+
+Before creating a release, ensure:
+
+1. Working tree is clean (`git status`)
+2. Tests pass locally (`go test ./...`)
+3. Coverage is at least 80%
+4. Dependencies are tidy (`go mod tidy`)
+5. Documentation is up to date (README/docs)
+
+## Create a Release
+
+### 1. Prepare (optional)
+
+Update docs, examples, or changelog if needed:
+
+```bash
+git add README.md docs/
+git commit -m "docs: prepare release"
+```
+
+### 2. Create tag
+
+```bash
+git tag -a v0.2.0 -m "Release v0.2.0"
+```
+
+### 3. Push tag
+
+```bash
+git push origin v0.2.0
+```
+
+### 4. Verify pipeline and release
+
+After pushing the tag, verify in Drone and Gitea:
+
+- Pipeline succeeded for tag build
+- Gitea release exists with artifacts:
+ - `.build/coverage.txt`
+ - `.build/sources.tar.gz`
+ - `.build/release-notes.md`
+
+## Install Released Version
+
+```bash
+# latest
+go get scm.yoorie.de/go-lib/gelf
+
+# specific
+go get scm.yoorie.de/go-lib/gelf@v0.2.0
+```
+
+## Troubleshooting
+
+### Coverage below threshold
+
+Run locally and inspect uncovered code:
+
+```bash
+go test -v -coverprofile .build/coverage.out ./...
+go tool cover -func .build/coverage.out
+```
+
+### Release step fails
+
+Common causes:
+
+- Missing Drone secret `gitea_token`
+- Tag does not match `v*`
+- Earlier pipeline step failed
+
+### Wrong tag pushed
+
+```bash
+git tag -d v0.2.0
+git push origin :refs/tags/v0.2.0
+```
+
+Then create and push the corrected tag.
+
+## Release Checklist
+
+- [ ] Tests pass locally
+- [ ] Coverage is at least 80%
+- [ ] `go vet ./...` passes
+- [ ] `govulncheck ./...` is clean or reviewed
+- [ ] Tag `vX.Y.Z` created and pushed
+- [ ] Drone pipeline succeeded
+- [ ] Gitea release contains expected artifacts
diff --git a/gelflogger_test.go b/gelflogger_test.go
new file mode 100644
index 0000000..703cde0
--- /dev/null
+++ b/gelflogger_test.go
@@ -0,0 +1,158 @@
+package gelf
+
+import (
+ "bytes"
+ "log"
+ "os"
+ "os/exec"
+ "testing"
+
+ . "github.com/smartystreets/goconvey/convey"
+ golf "gopkg.in/aphistic/golf.v0"
+)
+
+func TestSetDefaultConfig(t *testing.T) {
+ Convey("SetDefaultConfig with empty host keeps GELF client disabled", t, func() {
+ originalClient := c
+ defer func() { c = originalClient }()
+
+ c = nil
+ SetDefaultConfig("", 12201, map[string]interface{}{"service": "test"})
+
+ So(c, ShouldBeNil)
+ })
+
+ Convey("SetDefaultConfig with host initializes GELF client", t, func() {
+ originalClient := c
+ defer func() {
+ if c != nil {
+ c.Close()
+ }
+ c = originalClient
+ }()
+
+ c = nil
+ SetDefaultConfig("127.0.0.1", 12201, map[string]interface{}{"service": "test"})
+
+ So(c, ShouldNotBeNil)
+ })
+}
+
+func TestLoggingWithoutGelfClient(t *testing.T) {
+ Convey("Logging functions write to standard logger when GELF client is nil", t, func() {
+ originalClient := c
+ defer func() { c = originalClient }()
+ c = nil
+
+ buffer, restore := captureStandardLogger()
+ defer restore()
+
+ Debug("debug message")
+ Debugf("debugf %d", 42)
+ Info("info message")
+ Infof("infof %s", "ok")
+ Alert("alert message")
+ Alertf("alertf %d", 7)
+
+ output := buffer.String()
+ So(output, ShouldContainSubstring, "debug message")
+ So(output, ShouldContainSubstring, "debugf 42")
+ So(output, ShouldContainSubstring, "info message")
+ So(output, ShouldContainSubstring, "infof ok")
+ So(output, ShouldContainSubstring, "Alert: alert message")
+ So(output, ShouldContainSubstring, "Alert: alertf 7")
+ })
+}
+
+func TestLoggingWithGelfClient(t *testing.T) {
+ Convey("Logging functions execute without panic when GELF client is configured", t, func() {
+ originalClient := c
+ defer func() {
+ if c != nil {
+ c.Close()
+ }
+ c = originalClient
+ }()
+
+ client, err := golf.NewClient()
+ So(err, ShouldBeNil)
+ c = client
+ So(c.Dial("udp://127.0.0.1:12201"), ShouldBeNil)
+
+ logger, err := c.NewLogger()
+ So(err, ShouldBeNil)
+ golf.DefaultLogger(logger)
+
+ buffer, restore := captureStandardLogger()
+ defer restore()
+
+ So(func() { Debug("debug with gelf") }, ShouldNotPanic)
+ So(func() { Debugf("debugf %d", 1) }, ShouldNotPanic)
+ So(func() { Info("info with gelf") }, ShouldNotPanic)
+ So(func() { Infof("infof %d", 2) }, ShouldNotPanic)
+ So(func() { Alert("alert with gelf") }, ShouldNotPanic)
+ So(func() { Alertf("alertf %d", 3) }, ShouldNotPanic)
+
+ output := buffer.String()
+ So(output, ShouldContainSubstring, "debug with gelf")
+ So(output, ShouldContainSubstring, "Alert: alert with gelf")
+ })
+}
+
+func TestFatalExits(t *testing.T) {
+ Convey("Fatal exits with non-zero status and logs message", t, func() {
+ cmd := exec.Command(os.Args[0], "-test.run=TestFatalHelperProcess")
+ cmd.Env = append(os.Environ(), "GO_WANT_FATAL_HELPER=1", "FATAL_MODE=plain", "FATAL_WITH_GELF=1")
+
+ output, err := cmd.CombinedOutput()
+ So(err, ShouldNotBeNil)
+ So(string(output), ShouldContainSubstring, "Fatal: fatal message")
+ })
+}
+
+func TestFatalfExits(t *testing.T) {
+ Convey("Fatalf exits with non-zero status and logs formatted message", t, func() {
+ cmd := exec.Command(os.Args[0], "-test.run=TestFatalHelperProcess")
+ cmd.Env = append(os.Environ(), "GO_WANT_FATAL_HELPER=1", "FATAL_MODE=format", "FATAL_WITH_GELF=1")
+
+ output, err := cmd.CombinedOutput()
+ So(err, ShouldNotBeNil)
+ So(string(output), ShouldContainSubstring, "Fatal: formatted 9")
+ })
+}
+
+func TestFatalHelperProcess(t *testing.T) {
+ if os.Getenv("GO_WANT_FATAL_HELPER") != "1" {
+ return
+ }
+
+ log.SetFlags(0)
+ mode := os.Getenv("FATAL_MODE")
+
+ if os.Getenv("FATAL_WITH_GELF") == "1" {
+ SetDefaultConfig("127.0.0.1", 12201, map[string]interface{}{"service": "test"})
+ }
+
+ if mode == "format" {
+ Fatalf("formatted %d", 9)
+ }
+
+ Fatal("fatal message")
+}
+
+func captureStandardLogger() (*bytes.Buffer, func()) {
+ var buffer bytes.Buffer
+ originalWriter := log.Writer()
+ originalFlags := log.Flags()
+ originalPrefix := log.Prefix()
+
+ log.SetOutput(&buffer)
+ log.SetFlags(0)
+ log.SetPrefix("")
+
+ return &buffer, func() {
+ log.SetOutput(originalWriter)
+ log.SetFlags(originalFlags)
+ log.SetPrefix(originalPrefix)
+ }
+}
diff --git a/go.mod b/go.mod
index e85fb55..951f0d0 100644
--- a/go.mod
+++ b/go.mod
@@ -7,5 +7,9 @@ require gopkg.in/aphistic/golf.v0 v0.0.0-20180712155816-02c07f170c5a
require (
github.com/aphistic/sweet v0.3.0 // indirect
github.com/google/uuid v1.3.0 // indirect
+ github.com/gopherjs/gopherjs v1.17.2 // indirect
+ github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/onsi/gomega v1.27.2 // indirect
+ github.com/smarty/assertions v1.15.0 // indirect
+ github.com/smartystreets/goconvey v1.8.1 // indirect
)
diff --git a/go.sum b/go.sum
index dc950f7..4038af0 100644
--- a/go.sum
+++ b/go.sum
@@ -5,7 +5,11 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
+github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-isatty v0.0.5 h1:tHXDdz1cpzGaovsTB+TVB8q90WEokoVmfMqoVcrLUgw=
@@ -18,6 +22,10 @@ github.com/onsi/gomega v1.27.2 h1:SKU0CXeKE/WVgIV1T61kSa3+IRE8Ekrv9rdXDwwTqnY=
github.com/onsi/gomega v1.27.2/go.mod h1:5mR3phAHpkAVIDkHEUBY6HGVsU+cpcEscrGPB4oPlZI=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
+github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
+github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
+github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
+github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734 h1:p/H982KKEjUnLJkM3tt/LemDnOc1GiZL5FCVlORJ5zo=
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -30,6 +38,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
+golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
gopkg.in/aphistic/golf.v0 v0.0.0-20180712155816-02c07f170c5a h1:34vqlRjuZiE9c8eHsuZ9nn+GbcimFpvGUEmW+vyfhG8=
diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh
new file mode 100755
index 0000000..cf7e777
--- /dev/null
+++ b/scripts/check-coverage.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+# Check test coverage against minimum threshold.
+
+set -euo pipefail
+
+COVERAGE_FILE="${1:-.build/coverage.out}"
+MIN_COVERAGE="${2:-80}"
+
+if [ ! -f "$COVERAGE_FILE" ]; then
+ echo "Error: coverage file not found: $COVERAGE_FILE"
+ exit 1
+fi
+
+# Extract total coverage percentage from go tool cover output.
+COVERAGE=$(go tool cover -func "$COVERAGE_FILE" | awk '/^total:/ { match($0, /[0-9.]+%/); print substr($0, RSTART, RLENGTH-1) }')
+
+if [ -z "$COVERAGE" ]; then
+ echo "Error: failed to parse coverage from $COVERAGE_FILE"
+ exit 1
+fi
+
+echo "Total coverage: ${COVERAGE}%"
+
+# Compare as integer part to keep shell arithmetic simple and portable.
+COVERAGE_INT=${COVERAGE%.*}
+
+if [ "$COVERAGE_INT" -lt "$MIN_COVERAGE" ]; then
+ echo "Coverage ${COVERAGE}% is below minimum ${MIN_COVERAGE}%"
+ exit 1
+fi
+
+echo "Coverage check passed"
diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh
new file mode 100755
index 0000000..b0b471f
--- /dev/null
+++ b/scripts/generate-release-notes.sh
@@ -0,0 +1,70 @@
+#!/bin/bash
+# Generate release notes from commits since the previous tag.
+
+set -euo pipefail
+
+CURRENT_TAG="${1:-${DRONE_TAG:-}}"
+OUTPUT_FILE="${2:-.build/release-notes.md}"
+
+if [ -z "$CURRENT_TAG" ]; then
+ echo "Error: current tag not provided"
+ exit 1
+fi
+
+PREVIOUS_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG"^ 2>/dev/null || echo "")
+
+mkdir -p "$(dirname "$OUTPUT_FILE")"
+
+{
+ echo "# Release $CURRENT_TAG"
+ echo
+
+ if [ -n "$PREVIOUS_TAG" ]; then
+ echo "Changes since $PREVIOUS_TAG:"
+ echo
+
+ git log "$PREVIOUS_TAG..$CURRENT_TAG" --pretty=format:"%s" | while read -r commit; do
+ if [[ $commit =~ ^([a-z]+)(\(.+\))?:\ (.+)$ ]]; then
+ TYPE="${BASH_REMATCH[1]}"
+ SCOPE="${BASH_REMATCH[2]}"
+ MESSAGE="${BASH_REMATCH[3]}"
+
+ case "$TYPE" in
+ feat)
+ echo "- Feature${SCOPE}: $MESSAGE" ;;
+ fix)
+ echo "- Fix${SCOPE}: $MESSAGE" ;;
+ docs)
+ echo "- Docs${SCOPE}: $MESSAGE" ;;
+ ci)
+ echo "- CI${SCOPE}: $MESSAGE" ;;
+ test)
+ echo "- Test${SCOPE}: $MESSAGE" ;;
+ refactor)
+ echo "- Refactor${SCOPE}: $MESSAGE" ;;
+ perf)
+ echo "- Performance${SCOPE}: $MESSAGE" ;;
+ *)
+ echo "- $commit" ;;
+ esac
+ else
+ echo "- $commit"
+ fi
+ done
+ else
+ echo "Initial release"
+ echo
+ git log "$CURRENT_TAG" --pretty=format:"- %s" | head -20
+ fi
+
+ echo
+ echo "---"
+ if [ -n "$PREVIOUS_TAG" ]; then
+ echo "See all changes: https://scm.yoorie.de/git/go-lib/gelf/compare/$PREVIOUS_TAG...$CURRENT_TAG"
+ else
+ echo "See all changes: https://scm.yoorie.de/git/go-lib/gelf/releases/tag/$CURRENT_TAG"
+ fi
+} > "$OUTPUT_FILE"
+
+echo "Release notes generated: $OUTPUT_FILE"
+cat "$OUTPUT_FILE"