diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c0b36c5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + # Maintain dependencies for Golang + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1556ab1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,197 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Release version, for example v0.5.1" + required: true + default: "v0.5.1" + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +env: + GOTOOLCHAIN: local + RELEASE_VERSION_INPUT: ${{ inputs.version }} + +jobs: + validate: + name: Validate release + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.version.outputs.version }} + changelog_version: ${{ steps.version.outputs.changelog_version }} + dispatch_sha: ${{ steps.dispatch.outputs.sha }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v7 + with: + go-version: "1.26" + cache: true + + - name: Resolve release version + id: version + shell: bash + env: + RELEASE_VERSION_RAW: ${{ env.RELEASE_VERSION_INPUT }} + run: | + version="$RELEASE_VERSION_RAW" + if [[ ! "$version" =~ ^v.+$ ]]; then + echo "Invalid release version: $version (expected v-prefix SemVer)" >&2 + exit 1 + fi + changelog_version="${version#v}" + bash scripts/prepare-release.sh "$changelog_version" --semver-only + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "changelog_version=$changelog_version" >> "$GITHUB_OUTPUT" + + - name: Record dispatch SHA + id: dispatch + run: echo "sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + + - name: Verify dispatch runs from current main + shell: bash + env: + DISPATCH_SHA: ${{ github.sha }} + run: | + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "Release dispatch must run from main (got $GITHUB_REF)" >&2 + exit 1 + fi + git fetch origin main + origin_main=$(git rev-parse origin/main) + if [[ "$DISPATCH_SHA" != "$origin_main" ]]; then + echo "Dispatch SHA $DISPATCH_SHA is not current origin/main ($origin_main)" >&2 + exit 1 + fi + + - name: Validate release scripts + run: | + bash -n scripts/*.sh + bash scripts/prepare-release-test.sh + + - name: Check changelog readiness + env: + CHANGELOG_VERSION: ${{ steps.version.outputs.changelog_version }} + run: bash scripts/prepare-release.sh "$CHANGELOG_VERSION" --check-only + + - name: Lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.11.4 + + - name: Full release validation + run: bash scripts/test-all.sh + + publish: + name: Publish GitHub Release + needs: validate + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Finalize changelog on main + shell: bash + env: + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + CHANGELOG_VERSION: ${{ needs.validate.outputs.changelog_version }} + DISPATCH_SHA: ${{ needs.validate.outputs.dispatch_sha }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git fetch origin main + origin_main=$(git rev-parse origin/main) + if [[ "$DISPATCH_SHA" != "$origin_main" ]]; then + echo "origin/main ($origin_main) moved since dispatch ($DISPATCH_SHA)" >&2 + exit 1 + fi + + git checkout main + git pull --ff-only origin main + bash scripts/prepare-release.sh "$CHANGELOG_VERSION" + + if ! git diff --quiet -- CHANGELOG.md; then + git add CHANGELOG.md + git commit -m "chore(release): prepare CHANGELOG for ${RELEASE_VERSION}" + git push origin main + fi + + - name: Resolve release commit SHA + id: release_sha + shell: bash + env: + DISPATCH_SHA: ${{ needs.validate.outputs.dispatch_sha }} + run: | + release_sha=$(git rev-parse HEAD) + if [[ "$release_sha" == "$DISPATCH_SHA" ]]; then + echo "changelog was already finalized on the validated commit" + elif [[ $(git rev-list --count "${DISPATCH_SHA}..HEAD") -eq 1 ]]; then + parent_sha=$(git rev-parse HEAD^) + changed_files=$(git diff-tree --no-commit-id --name-only -r HEAD) + if [[ "$parent_sha" != "$DISPATCH_SHA" || "$changed_files" != "CHANGELOG.md" ]]; then + echo "release commit must be a single changelog-only child of $DISPATCH_SHA" >&2 + exit 1 + fi + else + echo "unexpected commits between validated and release state" >&2 + exit 1 + fi + echo "sha=$release_sha" >> "$GITHUB_OUTPUT" + + - name: Verify finalized changelog + env: + CHANGELOG_VERSION: ${{ needs.validate.outputs.changelog_version }} + run: bash scripts/prepare-release.sh "$CHANGELOG_VERSION" --require-final + + - name: Verify existing tag + shell: bash + env: + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + RELEASE_SHA: ${{ steps.release_sha.outputs.sha }} + run: | + git fetch origin --tags --force + if git rev-parse --verify "refs/tags/${RELEASE_VERSION}" >/dev/null 2>&1; then + existing_sha=$(git rev-parse "refs/tags/${RELEASE_VERSION}^{commit}") + if [[ "$existing_sha" != "$RELEASE_SHA" ]]; then + echo "tag $RELEASE_VERSION points to $existing_sha, expected $RELEASE_SHA" >&2 + exit 1 + fi + echo "tag $RELEASE_VERSION already points to the release commit" + fi + + - name: Extract release notes + shell: bash + env: + CHANGELOG_VERSION: ${{ needs.validate.outputs.changelog_version }} + run: | + prefix="## [${CHANGELOG_VERSION}] - " + awk -v prefix="$prefix" ' + index($0, prefix) == 1 { found = 1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md > release-notes.md + if [[ ! -s release-notes.md ]]; then + echo "errors ${{ needs.validate.outputs.version }}" > release-notes.md + echo >> release-notes.md + echo "See CHANGELOG.md for details." >> release-notes.md + fi + + - name: Publish source-only GitHub Release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ needs.validate.outputs.version }} + target_commitish: ${{ steps.release_sha.outputs.sha }} + name: errors ${{ needs.validate.outputs.version }} + body_path: release-notes.md + prerelease: ${{ contains(needs.validate.outputs.version, '-') }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e9c9b14..83acb9e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,25 +8,41 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + env: + GOTOOLCHAIN: local steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v7 with: - go-version: ^1.21 - cache-dependency-path: go.sum - id: go + go-version: "1.26" + cache: true - - name: Set up dependencies - run: go mod download + - name: Format check + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "gofmt needed for:" + echo "$unformatted" + exit 1 + fi + + - name: Vet + run: go vet ./... - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.6.1 + version: v2.11.4 - name: Run tests run: go test -race -v ./... + + - name: Module tidy check + run: go mod tidy -diff + + - name: Module verify + run: go mod verify diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..372f2b8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# Project agent rules + +This repository is `github.com/muonsoft/errors`. + +## Sources of truth + +- `README.md` — public API and slog integration notes. +- `CHANGELOG.md` — Keep a Changelog record of user-visible changes. +- `docs/release-checklist.md` — release procedure and verification. +- `MIGRATION.md` — breaking-change guidance for v0.4.x → v0.5.0. + +## Release policy + +- Update `CHANGELOG.md` `[Unreleased]` when behavior or public docs change. +- Local agents and scripts never create or push release tags. The + maintainer-dispatched Release workflow is the only path authorized to push its + changelog-only commit and create a release tag. + +## Work discipline + +- Preserve unrelated user changes. +- Keep changes focused and commits atomic. +- Run checks proportional to the change (`gofmt`, `go test`, `go test -race`) and + `bash scripts/test-all.sh` before publication. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c027153 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and +this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `Log` and `LogLevel` attach a typed `"error"` slog attribute so backends such as + sentry-go/slog can call `SetException`. The value unwraps to the original chain and + does not implement `slog.LogValuer`. +- Maintainer-dispatched GitHub Release workflow with changelog finalization and + CI-owned tag creation. + +## [0.5.0] - 2026-02-10 + +Native `log/slog` integration. This release replaces the custom field system with +`slog.Attr` as the core type for error attributes. The logrus adapter has been +removed. + +### Added + +- Native `slog.Attr` support in `Wrap` and `Errorf`, including `slog.Group`. +- `Attrs(err)` to extract attributes from an error chain. +- `Log(ctx, logger, err)` and `LogLevel(ctx, logger, level, err)` for slog logging. +- Attribute options `Int64`, `Uint64`, `Float64`, and `Any`. +- `slog.LogValuer` on wrapped errors. + +### Changed + +- `LoggableError` now requires `Attrs() []slog.Attr` instead of `LogFields(FieldLogger)`. +- `Log(err, logger)` is replaced by `Log(ctx, logger, err)`. +- Minimum Go version is 1.21. + +### Deprecated + +- `Value` is deprecated in favor of `Any`. + +### Removed + +- Custom `Field` types and `FieldLogger`. +- logrus adapter. See the [Migration Guide](MIGRATION.md) for a small custom adapter + if needed. + +[Unreleased]: https://github.com/muonsoft/errors/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/muonsoft/errors/releases/tag/v0.5.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3903d6b..df51a7b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,15 +1,19 @@ # Contributing -When contributing to this repository, please first discuss the change you wish to -make via [issue](https://github.com/muonsoft/errors/issues), or any other method with the -owners of this repository before making a change. +When contributing to this repository, please first discuss the change you wish to +make via [issue](https://github.com/muonsoft/errors/issues), or any other method with the +owners of this repository before making a change. -Please note we have a [code of conduct](CODE_OF_CONDUCT.md), please follow it in +Please note we have a [code of conduct](CODE_OF_CONDUCT.md), please follow it in all your interactions with the project. ## Pull Request Process 1. Ensure any install or build dependencies are removed before pushing to the git repository. 2. Update the `README.md` with details of changes if necessary. -3. You may merge the Pull Request in once you have the sign-off of at least one developer, or if you +3. Record user-visible changes in the `CHANGELOG.md` `[Unreleased]` section. +4. You may merge the Pull Request in once you have the sign-off of at least one developer, or if you do not have permission to do that, you may request the reviewer to merge it for you. + +Do not create or push release tags. Releases are published by a maintainer through the +GitHub Actions **Release** workflow. See [`docs/release-checklist.md`](docs/release-checklist.md). diff --git a/README.md b/README.md index cb64f4e..85e25d3 100644 --- a/README.md +++ b/README.md @@ -318,10 +318,23 @@ err := errors.Errorf( ), ) -// Log error at Error level with all attributes and stack trace +// Log error at Error level with all attributes, stack trace, and a typed "error" attr errors.Log(ctx, slog.Default(), err) ``` +`Log` and `LogLevel` attach an `"error"` attribute whose resolved value is a Go `error`. +Slog backends such as `sentry-go/slog` use that to call `SetException` instead of +emitting a message-only event. + +`slog.JSONHandler` serializes that attribute as a string, so JSON logs repeat the +message in both `msg` and `error`. Drop or rewrite it with `ReplaceAttr` (or a +Graylog blacklist) if the duplicate is unwanted. + +The attribute is a thin wrapper around the original error: it unwraps to the muonsoft +chain (including `Join`) but does not implement `slog.LogValuer`. Sentry will show an +extra `errors.logError` frame in the Exception list; the stack trace stays on the +muonsoft link. That extra frame cannot be removed from this package. + #### Extracting attributes manually ```golang @@ -334,13 +347,14 @@ err := errors.Errorf( // Extract all attributes from error chain attrs := errors.Attrs(err) -// Use with slog +// Use with slog. Prefer errors.Log when a backend needs a typed error value. slog.Error("request failed", append([]any{slog.Any("error", err)}, attrsToAny(attrs)...)...) ``` #### Using slog.LogValuer -Errors automatically work as `slog.LogValuer`, so you can log them directly: +Errors automatically work as `slog.LogValuer`, so you can log them directly and get +their attributes as a group: ```golang err := errors.Wrap( @@ -349,10 +363,13 @@ err := errors.Wrap( errors.Int("id", 123), ) -// The error will automatically provide its attributes to slog +// Attributes are expanded as a group. After Resolve() the value is not a Go error. slog.Error("database error", "error", err) ``` +Do not use `slog.Any("error", err)` when a handler needs `Resolve().Any().(error)` to +succeed (for example Sentry). Use `errors.Log` / `errors.LogLevel` instead. + ### Custom LoggableError types You can implement `errors.LoggableError` interface on your custom error types to provide @@ -432,6 +449,17 @@ You may help this project by If you'd like to contribute, see [the contribution guide](CONTRIBUTING.md). Pull requests are welcome. +## Releases + +Releases are source-only GitHub Releases published by a maintainer through the +repository's **Release** workflow. The workflow revalidates the selected `main` +commit, finalizes the Keep a Changelog section, pushes at most one changelog-only +release commit, and asks GitHub to create the release tag at that verified commit. + +Do not create or push release tags locally. See +[`docs/release-checklist.md`](docs/release-checklist.md) for preflight, dispatch, and +post-release verification. + ## License This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md deleted file mode 100644 index 2eac3db..0000000 --- a/RELEASE_NOTES.md +++ /dev/null @@ -1,27 +0,0 @@ -# Release notes - -## v0.5.0 - -**Native `log/slog` integration.** This release replaces the custom field system with `slog.Attr` as the core type for error attributes. The logrus adapter has been removed. - -### Highlights - -- **`slog.Attr` everywhere** — Use `slog.String()`, `slog.Int()`, `slog.Group()`, etc. directly in `errors.Wrap()` and `errors.Errorf()` alongside existing options. -- **`errors.Attrs(err)`** — Extract all attributes from an error chain for custom logging. -- **`errors.Log(ctx, logger, err)`** — Log an error at Error level with attributes and stack trace. -- **`errors.LogLevel(ctx, logger, level, err)`** — Same as above with an explicit level (Debug, Info, Warn, Error). -- **New attribute options** — `Int64`, `Uint64`, `Float64`, `Any`. `Value` is deprecated in favor of `Any`. -- **Grouped attributes** — Full support for `slog.Group` in errors, JSON marshaling, and `%+v` formatting (dot notation). -- **`slog.LogValuer`** — Errors implement `LogValuer` for native slog handling. - -### Breaking changes - -- Custom `Field` types and `FieldLogger` are removed; use `slog.Attr` and `*slog.Logger`. -- `LoggableError` now requires `Attrs() []slog.Attr` instead of `LogFields(FieldLogger)`. -- `errors.Log(err, logger)` replaced by `errors.Log(ctx, logger, err)`. -- Package requires **Go 1.21+**. -- **logrus adapter removed** — see migration guide for a small custom adapter if needed. - -### Migration - -**If you are upgrading from v0.4.x, read the [Migration Guide](https://github.com/muonsoft/errors/blob/main/MIGRATION.md)** for step-by-step instructions and before/after examples. diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 0000000..40e0bd7 --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,80 @@ +# Release checklist + +Release publication is a maintainer action through the GitHub Actions **Release** +workflow. Local scripts and autonomous agents validate and prepare releases but never +create or push release tags. The source-only GitHub Release creates the tag at the +workflow-verified release commit. + +## Preflight + +- [ ] Work is merged to `main` and the branch is not moving during publication. +- [ ] Normal CI is green. +- [ ] `CHANGELOG.md` has either a non-empty exact planned version section or non-empty + `[Unreleased]` section. +- [ ] README and migration guidance are current. +- [ ] No local or remote tag with the requested version points to another commit. +- [ ] Repository Actions settings allow `GITHUB_TOKEN` write access, and branch rules + permit `github-actions[bot]` to push the changelog-only release commit. + +## Local validation + +Run from the repository root: + +```bash +bash -n scripts/*.sh +bash scripts/prepare-release-test.sh +bash scripts/prepare-release.sh 0.5.1 --check-only +bash scripts/test-all.sh +git diff --check +git status --short +git tag --list v0.5.1 +``` + +## Dispatch the release + +1. Open **Actions → Release → Run workflow** in GitHub. +2. Select `main` and enter a `v`-prefixed SemVer version, for example `v0.5.1`. +3. Confirm the validation job succeeds. +4. Confirm the publish job creates at most one changelog-only commit on `main`. +5. Confirm the source-only GitHub Release and its tag point to that release commit. + +The workflow will stop if it was dispatched from a branch other than current `main`, +if `main` moves after validation, if the changelog cannot be finalized, or if the tag +already points to a different commit. + +## What the workflow does + +1. Validates strict SemVer and records the exact dispatch SHA. +2. Runs changelog script tests and checks that release notes are ready. +3. Runs lint and the full unit/race/format/module gate. +4. Rechecks `origin/main`, finalizes the changelog with the UTC release date, and + pushes a changelog-only release commit when necessary. +5. Verifies that the release commit is either the validated SHA or its single + changelog-only child. +6. Extracts release notes and publishes a source-only GitHub Release. GitHub creates + the missing tag at the verified release commit. + +## Post-release verification + +```bash +gh release view v0.5.1 --repo muonsoft/errors +git ls-remote --tags origin refs/tags/v0.5.1 + +tmp_dir=$(mktemp -d) +cd "$tmp_dir" +go mod init release-smoke +GOTOOLCHAIN=local go get github.com/muonsoft/errors@v0.5.1 +go list -m github.com/muonsoft/errors +``` + +After verification, pull the changelog release commit into local clones. Do not create +a second local tag or move the published tag. + +## Failure and rollback + +- A validation failure creates neither a changelog commit nor a tag. +- A failure before GitHub Release publication may leave a valid changelog-only commit; + rerun the workflow for the same version after diagnosing the failure. +- If a release or tag was published incorrectly, follow repository governance, + document a retraction, and publish a corrective version. Do not rewrite public + history or silently move a consumed tag. diff --git a/logging.go b/logging.go index de72f5d..594220c 100644 --- a/logging.go +++ b/logging.go @@ -59,6 +59,11 @@ func Log(ctx context.Context, logger *slog.Logger, err error) { // LogLevel logs an error at the specified level with all its structured attributes // and stack trace using the provided slog.Logger. // +// The record includes an "error" attribute whose resolved value is a Go error. +// That lets slog backends such as sentry-go/slog call SetException. The wrapper +// used for that attribute does not implement slog.LogValuer, so Resolve keeps +// the typed error instead of expanding muonsoft attributes into a group. +// // If err is nil, this function does nothing. // // Example: @@ -70,10 +75,8 @@ func LogLevel(ctx context.Context, logger *slog.Logger, level slog.Level, err er return } - // Collect all attributes attrs := Attrs(err) - // Find and add stack trace if present for e := err; e != nil; e = errors.Unwrap(e) { if s, ok := e.(stackTracer); ok { attrs = append(attrs, slog.Any("stackTrace", s.StackTrace())) @@ -81,11 +84,17 @@ func LogLevel(ctx context.Context, logger *slog.Logger, level slog.Level, err er } } - // Convert attrs to []any for logger.Log - args := make([]any, len(attrs)) - for i, attr := range attrs { - args[i] = attr - } + attrs = append(attrs, errorAttr(err)) + logger.LogAttrs(ctx, level, err.Error(), attrs...) +} + +// logError carries the original error into a slog record without implementing +// slog.LogValuer or json.Marshaler. Backends that look up "error"/"err" can +// type-assert the resolved value to error and unwrap the original chain. +type logError struct{ error } + +func (e logError) Unwrap() error { return e.error } - logger.Log(ctx, level, err.Error(), args...) +func errorAttr(err error) slog.Attr { + return slog.Any("error", logError{err}) } diff --git a/logging_test.go b/logging_test.go index e27e8ca..0a36278 100644 --- a/logging_test.go +++ b/logging_test.go @@ -1,7 +1,9 @@ package errors_test import ( + "bytes" "context" + "encoding/json" stderrors "errors" "log/slog" "testing" @@ -149,6 +151,123 @@ func TestLog(t *testing.T) { } } +func TestLog_errorAttrIsTypedError(t *testing.T) { + orig := errors.Wrap( + errors.Errorf("typed error", errors.String("user", "john"), errors.Int("id", 123)), + ) + + attr, ok := lastTypedErrorAttr(captureLogAttrs(t, orig)) + if !ok { + t.Fatal("expected typed 'error' attribute") + } + if attr.Value.Kind() == slog.KindLogValuer { + t.Fatal("expected 'error' attribute not to be KindLogValuer") + } + + resolved, ok := attr.Value.Resolve().Any().(error) + if !ok { + t.Fatalf("expected Resolve().Any() to be error, got %T", attr.Value.Resolve().Any()) + } + if _, isLogValuer := resolved.(slog.LogValuer); isLogValuer { + t.Fatal("expected wrapped log error not to implement slog.LogValuer") + } + if errors.Unwrap(resolved) != orig { + t.Fatal("expected Unwrap() to return the original error") + } + if !errors.Is(resolved, orig) { + t.Fatal("expected errors.Is to match the original error") + } + + var tracer interface{ StackTrace() errors.StackTrace } + if !stderrors.As(resolved, &tracer) { + t.Fatal("expected StackTrace() on the unwrapped muonsoft error") + } + if len(tracer.StackTrace()) == 0 { + t.Fatal("expected non-empty stack trace") + } +} + +func TestLog_errorAttrPreservesJoin(t *testing.T) { + err1 := errors.New("one") + err2 := errors.New("two") + joined := errors.Join(err1, err2) + + attr, ok := lastTypedErrorAttr(captureLogAttrs(t, joined)) + if !ok { + t.Fatal("expected typed 'error' attribute") + } + resolved, ok := attr.Value.Resolve().Any().(error) + if !ok { + t.Fatalf("expected Resolve().Any() to be error, got %T", attr.Value.Resolve().Any()) + } + if !errors.Is(resolved, err1) || !errors.Is(resolved, err2) { + t.Fatal("expected Join chain to be preserved through Unwrap") + } +} + +func TestLog_errorAttrAfterStringErrorField(t *testing.T) { + orig := errors.Wrap(errors.New("boom"), errors.String("error", "already used")) + + attrs := captureLogAttrs(t, orig) + typed, ok := lastTypedErrorAttr(attrs) + if !ok { + t.Fatal("expected typed 'error' attribute after string 'error' attr") + } + if _, isError := typed.Value.Resolve().Any().(error); !isError { + t.Fatal("expected last 'error' attribute to type-assert to error") + } + + hasStringError := false + for _, attr := range attrs { + if attr.Key == "error" && attr.Value.Kind() == slog.KindString { + hasStringError = true + break + } + } + if !hasStringError { + t.Fatal("expected original string 'error' attribute to remain") + } +} + +func TestSlogAnyErrorIsLogValuer(t *testing.T) { + orig := errors.Wrap(errors.Errorf("wrap me"), errors.String("key", "value")) + + attr := slog.Any("error", orig) + if attr.Value.Kind() != slog.KindLogValuer { + t.Fatalf("expected KindLogValuer, got %v", attr.Value.Kind()) + } + resolved := attr.Value.Resolve() + if resolved.Kind() != slog.KindGroup { + t.Fatalf("expected resolved group, got %v", resolved.Kind()) + } + if _, ok := resolved.Any().(error); ok { + t.Fatal("expected slog.Any without wrapper not to resolve to error") + } +} + +func TestLog_jsonHandlerSerializesErrorAsString(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, nil)) + orig := errors.Wrap(errors.New("boom"), errors.String("user", "john")) + + errors.Log(context.Background(), logger, orig) + + var payload map[string]any + if err := json.Unmarshal(buf.Bytes(), &payload); err != nil { + t.Fatal(err) + } + errorField, ok := payload["error"].(string) + if !ok { + t.Fatalf("expected error to be a string, got %T (%v)", payload["error"], payload["error"]) + } + if errorField != "boom" { + t.Errorf("expected error %q, got %q", "boom", errorField) + } + if payload["msg"] != "boom" { + t.Errorf("expected msg %q, got %v", "boom", payload["msg"]) + } +} + func TestLogLevel(t *testing.T) { for _, level := range []slog.Level{slog.LevelDebug, slog.LevelInfo, slog.LevelWarn, slog.LevelError} { t.Run(level.String(), func(t *testing.T) { @@ -192,3 +311,38 @@ func (h *testHandler) WithAttrs(attrs []slog.Attr) slog.Handler { func (h *testHandler) WithGroup(name string) slog.Handler { return h } + +func captureLogAttrs(t *testing.T, err error) []slog.Attr { + t.Helper() + + var captured []slog.Attr + handler := &testHandler{ + onHandle: func(ctx context.Context, r slog.Record) error { + r.Attrs(func(a slog.Attr) bool { + captured = append(captured, a) + return true + }) + return nil + }, + } + errors.Log(context.Background(), slog.New(handler), err) + if len(captured) == 0 { + t.Fatal("expected log attributes") + } + return captured +} + +func lastTypedErrorAttr(attrs []slog.Attr) (slog.Attr, bool) { + var found slog.Attr + ok := false + for _, attr := range attrs { + if attr.Key != "error" { + continue + } + if _, isError := attr.Value.Resolve().Any().(error); isError { + found = attr + ok = true + } + } + return found, ok +} diff --git a/scripts/prepare-release-test.sh b/scripts/prepare-release-test.sh new file mode 100755 index 0000000..feda86b --- /dev/null +++ b/scripts/prepare-release-test.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# Isolated tests for scripts/prepare-release.sh. CHANGELOG.md is never modified. + +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +prepare="$repo_root/scripts/prepare-release.sh" +test_root=$(mktemp -d "${TMPDIR:-/tmp}/errors-prepare-release-test.XXXXXX") +passed=0 +failed=0 + +cleanup() { + case "$test_root" in + "${TMPDIR:-/tmp}"/errors-prepare-release-test.*) rm -rf -- "$test_root" ;; + *) echo "refusing to remove unexpected test directory: $test_root" >&2 ;; + esac +} +trap cleanup EXIT INT HUP TERM + +pass() { + echo "PASS: $1" + passed=$((passed + 1)) +} + +fail() { + echo "FAIL: $1" >&2 + failed=$((failed + 1)) +} + +run_prepare() { + local changelog_file=$1 + local release_version=$2 + shift 2 + CHANGELOG_FILE="$changelog_file" "$prepare" "$release_version" "$@" +} + +fixture() { + local name=$1 + echo "$test_root/${name}.md" +} + +test_invalid_versions() { + local file + file=$(fixture invalid) + printf '## [Unreleased]\n' >"$file" + if run_prepare "$file" 01.2.3 --semver-only >/dev/null 2>&1 || + run_prepare "$file" 1.2.3-01 --semver-only >/dev/null 2>&1 || + run_prepare "$file" 1.2.3+build --semver-only >/dev/null 2>&1; then + fail "invalid SemVer rejected" + else + pass "invalid SemVer rejected" + fi +} + +test_planned_check_and_finalize() { + local file + file=$(fixture planned) + printf '%s\n' \ + '## [Unreleased]' \ + '' \ + '## [0.1.0] — planned' \ + '' \ + '### Added' \ + '' \ + '- first release' >"$file" + + if run_prepare "$file" 0.1.0 --check-only >/dev/null && + run_prepare "$file" 0.1.0 2026-09-04 >/dev/null && + grep -qFx '## [0.1.0] - 2026-09-04' "$file"; then + pass "planned section finalized" + else + fail "planned section finalized" + fi +} + +test_finalize_is_idempotent() { + local file + file=$(fixture idempotent) + printf '%s\n' \ + '## [Unreleased]' \ + '' \ + '## [0.2.0] - 2026-09-04' \ + '' \ + '### Changed' \ + '' \ + '- existing' >"$file" + + if run_prepare "$file" 0.2.0 2026-09-05 >/dev/null && + grep -qFx '## [0.2.0] - 2026-09-04' "$file"; then + pass "finalization is idempotent" + else + fail "finalization is idempotent" + fi +} + +test_unreleased_promotion() { + local file + file=$(fixture unreleased) + printf '%s\n' \ + '## [Unreleased]' \ + '' \ + '### Fixed' \ + '' \ + '- future fix' \ + '' \ + '## [0.1.0] - 2026-09-01' >"$file" + + if run_prepare "$file" 0.1.1 2026-09-04 >/dev/null && + grep -qFx '## [0.1.1] - 2026-09-04' "$file" && + grep -qFx -- '- future fix' "$file"; then + pass "unreleased section promoted" + else + fail "unreleased section promoted" + fi +} + +test_require_final() { + local file + file=$(fixture require-final) + printf '%s\n' \ + '## [Unreleased]' \ + '' \ + '## [0.3.0] — planned' \ + '' \ + '### Added' \ + '' \ + '- pending' >"$file" + + if run_prepare "$file" 0.3.0 --require-final >/dev/null 2>&1; then + fail "require-final rejects planned section" + else + pass "require-final rejects planned section" + fi +} + +test_require_final_accepts_finalized() { + local file + file=$(fixture require-finalized) + printf '%s\n' \ + '## [Unreleased]' \ + '' \ + '## [0.3.1] - 2026-09-04' \ + '' \ + '### Fixed' \ + '' \ + '- ready' >"$file" + + if run_prepare "$file" 0.3.1 --require-final >/dev/null; then + pass "require-final accepts finalized section" + else + fail "require-final accepts finalized section" + fi +} + +test_empty_sections_fail() { + local file + file=$(fixture empty) + printf '%s\n' '## [Unreleased]' '' '## [0.4.0] — planned' >"$file" + if run_prepare "$file" 0.4.0 --check-only >/dev/null 2>&1; then + fail "empty sections rejected" + else + pass "empty sections rejected" + fi +} + +test_invalid_date_fails() { + local file + file=$(fixture date) + printf '%s\n' '## [Unreleased]' '' '### Added' '' '- item' >"$file" + if run_prepare "$file" 0.5.0 04-09-2026 >/dev/null 2>&1; then + fail "invalid date rejected" + else + pass "invalid date rejected" + fi +} + +test_invalid_versions +test_planned_check_and_finalize +test_finalize_is_idempotent +test_unreleased_promotion +test_require_final +test_require_final_accepts_finalized +test_empty_sections_fail +test_invalid_date_fails + +echo "prepare-release-test: ${passed} passed, ${failed} failed" +if [[ "$failed" -ne 0 ]]; then + exit 1 +fi diff --git a/scripts/prepare-release.sh b/scripts/prepare-release.sh new file mode 100755 index 0000000..e3d1bd8 --- /dev/null +++ b/scripts/prepare-release.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# Prepare CHANGELOG.md for a SemVer release (Keep a Changelog). +# +# Supports an exact planned section (`## [X.Y.Z] — planned`), promotion of a +# non-empty [Unreleased] section, validation-only modes, and idempotent reruns. +# +# Usage: +# scripts/prepare-release.sh 0.1.0 [YYYY-MM-DD] +# scripts/prepare-release.sh 0.1.0 --check-only +# scripts/prepare-release.sh 0.1.0 --require-final +# scripts/prepare-release.sh 0.1.0 --semver-only + +set -euo pipefail + +version=${1:?version required (for example 0.1.0)} +changelog=${CHANGELOG_FILE:-CHANGELOG.md} +release_date= +check_only=false +require_final=false +semver_only=false + +shift +while [[ $# -gt 0 ]]; do + case "$1" in + --check-only) + check_only=true + ;; + --require-final) + require_final=true + ;; + --semver-only) + semver_only=true + ;; + --*) + echo "unknown option: $1" >&2 + exit 1 + ;; + *) + if [[ -n "$release_date" ]]; then + echo "unexpected extra argument: $1" >&2 + exit 1 + fi + release_date=$1 + ;; + esac + shift +done + +validate_numeric_identifier() { + local identifier=$1 + local label=$2 + if [[ ! "$identifier" =~ ^(0|[1-9][0-9]*)$ ]]; then + echo "invalid ${label} identifier: ${identifier}" >&2 + return 1 + fi +} + +validate_semver() { + local candidate=$1 + local core prerelease major minor patch identifier + + if [[ "$candidate" == *+* ]]; then + echo "build metadata is not supported in release versions: ${candidate}" >&2 + return 1 + fi + + core=${candidate%%-*} + prerelease= + if [[ "$candidate" == *-* ]]; then + prerelease=${candidate#*-} + fi + + IFS=. read -r major minor patch <<<"$core" + if [[ -z "${major:-}" || -z "${minor:-}" || -z "${patch:-}" || "$core" == *.*.*.* ]]; then + echo "invalid SemVer core: ${candidate}" >&2 + return 1 + fi + validate_numeric_identifier "$major" major || return 1 + validate_numeric_identifier "$minor" minor || return 1 + validate_numeric_identifier "$patch" patch || return 1 + + if [[ "$candidate" == *-* ]]; then + if [[ -z "$prerelease" || "$prerelease" == .* || "$prerelease" == *. || "$prerelease" == *..* ]]; then + echo "invalid prerelease identifiers: ${candidate}" >&2 + return 1 + fi + IFS=. read -ra prerelease_parts <<<"$prerelease" + for identifier in "${prerelease_parts[@]}"; do + if [[ "$identifier" =~ ^[0-9]+$ ]]; then + validate_numeric_identifier "$identifier" prerelease || return 1 + elif [[ ! "$identifier" =~ ^[0-9A-Za-z-]+$ ]]; then + echo "invalid prerelease identifier: ${identifier}" >&2 + return 1 + fi + done + fi +} + +validate_semver "$version" +if [[ "$semver_only" == true ]]; then + exit 0 +fi + +if [[ ! -f "$changelog" ]]; then + echo "changelog file not found: $changelog" >&2 + exit 1 +fi + +if [[ -z "$release_date" ]]; then + release_date=$(date -u +%Y-%m-%d) +fi +if [[ ! "$release_date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + echo "invalid release date: $release_date (expected YYYY-MM-DD)" >&2 + exit 1 +fi + +final_prefix="## [${version}] - " +planned_header="## [${version}] — planned" + +is_finalized() { + awk -v prefix="$final_prefix" ' + index($0, prefix) == 1 { + value = substr($0, length(prefix) + 1) + if (value ~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) { + found = 1 + exit + } + } + END { exit(found ? 0 : 1) } + ' "$changelog" +} + +is_planned() { + grep -qFx "$planned_header" "$changelog" +} + +section_has_content() { + local header=$1 + awk -v header="$header" ' + $0 == header { in_section = 1; next } + in_section && /^## \[/ { exit } + in_section && /^### / { found = 1; exit } + END { exit(found ? 0 : 1) } + ' "$changelog" +} + +if is_finalized; then + echo "changelog section [${version}] is finalized" + exit 0 +fi + +if [[ "$require_final" == true ]]; then + echo "changelog section [${version}] is not finalized" >&2 + exit 1 +fi + +source_section= +if is_planned && section_has_content "$planned_header"; then + source_section=planned +elif grep -qFx '## [Unreleased]' "$changelog" && section_has_content '## [Unreleased]'; then + source_section=unreleased +fi + +if [[ -z "$source_section" ]]; then + echo "no non-empty planned [${version}] or [Unreleased] section in $changelog" >&2 + exit 1 +fi + +if [[ "$check_only" == true ]]; then + echo "${source_section} changelog section is ready for ${version}" + exit 0 +fi + +temporary=$(mktemp "${TMPDIR:-/tmp}/errors-changelog.XXXXXX") +cleanup() { + rm -f "$temporary" +} +trap cleanup EXIT INT HUP TERM + +if [[ "$source_section" == planned ]]; then + awk -v planned="$planned_header" -v final="${final_prefix}${release_date}" ' + $0 == planned { print final; next } + { print } + ' "$changelog" >"$temporary" +else + awk -v final="${final_prefix}${release_date}" ' + $0 == "## [Unreleased]" { + print "## [Unreleased]" + print "" + print final + next + } + { print } + ' "$changelog" >"$temporary" +fi + +mv "$temporary" "$changelog" +trap - EXIT INT HUP TERM +echo "finalized changelog section [${version}] - ${release_date}" diff --git a/scripts/test-all.sh b/scripts/test-all.sh new file mode 100755 index 0000000..6bca5c2 --- /dev/null +++ b/scripts/test-all.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$repo_root" + +echo "==> format check" +unformatted=$(gofmt -l .) +if [[ -n "$unformatted" ]]; then + echo "gofmt needed for:" >&2 + echo "$unformatted" >&2 + exit 1 +fi + +echo "==> vet" +go vet ./... + +if command -v golangci-lint >/dev/null 2>&1; then + echo "==> lint" + golangci-lint run +fi + +echo "==> unit tests" +go test ./... + +echo "==> race tests" +go test -race ./... + +echo "==> module tidy" +go mod tidy -diff + +echo "==> module verify" +go mod verify + +echo "==> all checks passed"