diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..989e6a0 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,94 @@ +version: 2 + +project_name: envc + +before: + hooks: + - go mod tidy + - go mod verify + +builds: + - id: envc + main: ./cmd/envc + binary: envc + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + flags: + - -trimpath + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + +archives: + - id: envc + ids: [envc] + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + formats: [zip] + files: + - LICENSE + - README.md + - CHANGELOG.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +snapshot: + version_template: "{{ .Tag }}-next" + +changelog: + use: github + sort: asc + groups: + - title: Features + regexp: '^.*?feat(\(.+\))??!?:.+$' + order: 0 + - title: "Bug fixes" + regexp: '^.*?fix(\(.+\))??!?:.+$' + order: 1 + - title: "Performance" + regexp: '^.*?perf(\(.+\))??!?:.+$' + order: 2 + - title: Others + order: 999 + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" + - "^chore:" + - "^style:" + - "^build:" + - "Merge pull request" + - "Merge branch" + +release: + draft: false + prerelease: auto + mode: replace + header: | + ## go-config `{{ .Tag }}` + + Install the `envc` CLI: + + ```sh + go install github.com/eslider/go-config/cmd/envc@{{ .Tag }} + ``` + + Or download a prebuilt binary below. + footer: | + **Full Changelog**: https://github.com/eSlider/go-config/compare/{{ .PreviousTag }}...{{ .Tag }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..2be9c43 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.2.0" +} diff --git a/AGENTS.md b/AGENTS.md index b057a08..11af450 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,15 +28,24 @@ Architecture Significant Requirements: [docs/asr/README.md](docs/asr/README.md). ## Checklist before release +Local sanity check (CI runs the same on every PR): + ```sh -cd go-config go mod tidy go vet ./... -go test -race -count=1 ./... +go test -race -shuffle=on -count=1 ./... golangci-lint run --timeout 5m ``` -Bump `CHANGELOG.md`, tag `vX.Y.Z`, push. +Versioning is **automated via [release-please](https://github.com/googleapis/release-please-action)** +and [GoReleaser](https://goreleaser.com) — do **not** hand-edit version strings or tag manually: + +1. Commit using [Conventional Commits](https://www.conventionalcommits.org/) + (`feat:`, `fix:`, `feat!:` for breaking, etc.). +2. `.github/workflows/release-please.yml` opens a release PR on each push to `main` + with the computed next SemVer and an updated `CHANGELOG.md`. +3. Merging that PR creates the `vX.Y.Z` tag. `.github/workflows/release.yml` then runs + GoReleaser to publish cross-platform `envc` binaries and a GitHub Release. ## Related diff --git a/README.md b/README.md index 14e3642..e5c726c 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/eslider/go-config.svg)](https://pkg.go.dev/github.com/eslider/go-config) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Latest Release](https://img.shields.io/github/v/release/eSlider/go-config)](https://github.com/eSlider/go-config/releases/latest) +[![Tests](https://github.com/eSlider/go-config/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/eSlider/go-config/actions/workflows/test.yml) +[![Lint](https://github.com/eSlider/go-config/actions/workflows/lint.yml/badge.svg?branch=main)](https://github.com/eSlider/go-config/actions/workflows/lint.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/eslider/go-config)](https://goreportcard.com/report/github.com/eslider/go-config) [![GitHub Stars](https://img.shields.io/github/stars/eSlider/go-config?style=social)](https://github.com/eSlider/go-config/stargazers) Convert **env**, **YAML**, **JSON**, and **INI** to and from Go `map[string]any` and structs. Multi-source inputs merge with **deep map merge**: nested maps combine, **scalar leaves are last-write-wins**, and **slices** default to **replace** (opt-in **concat** via `WithSliceMerge`). Keys are normalized with a configurable **lower+alnum** rule so `sub-service`, `SUB_SERVICE`, and `SubService` line up across formats. Built on [go-viper/mapstructure/v2](https://github.com/go-viper/mapstructure). @@ -119,6 +122,27 @@ INI uses dotted sections, e.g. `[service.subservice]` with `name=...`. | [go-onlyoffice](https://github.com/eSlider/go-onlyoffice) | OnlyOffice API | | [go-ollama](https://github.com/eSlider/go-ollama) | Ollama client | +## Release flow + +Versions are **computed from git history**, never hardcoded in source. Commit with +[Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `feat!:` …) +and the pipeline takes care of the rest: + +1. Every push / PR runs [`test.yml`](.github/workflows/test.yml) (matrix: Go 1.22, 1.23, + stable × linux/macOS/windows, `-race -shuffle=on`) and + [`lint.yml`](.github/workflows/lint.yml) (`go vet` + `golangci-lint`). +2. On merges to `main`, [`release-please.yml`](.github/workflows/release-please.yml) + parses commits since the last tag and opens a "release PR" that bumps `CHANGELOG.md` + and `.release-please-manifest.json` to the next SemVer. +3. Merging that PR creates the git tag `vX.Y.Z` and a GitHub Release. +4. The tag triggers [`release.yml`](.github/workflows/release.yml), which runs + [GoReleaser](https://goreleaser.com) to cross-compile `envc` (linux/darwin/windows × + amd64/arm64) with `-X main.version={{.Version}}` injected at link time and attach + archives + `checksums.txt` to the release. + +No version string lives in Go source — `envc version` prints the value baked in by the +release build, or `dev` for local `go install` builds. + ## Decisions Repo-local ASRs: [docs/asr/README.md](docs/asr/README.md). diff --git a/cmd/envc/main.go b/cmd/envc/main.go index 95b3cbe..9d0a112 100644 --- a/cmd/envc/main.go +++ b/cmd/envc/main.go @@ -5,11 +5,19 @@ import ( "os" ) +// Build metadata injected at release time via -ldflags. +// Defaults keep local `go build` / `go install` builds identifiable as unversioned. +var ( + version = "dev" + commit = "none" + date = "unknown" +) + func main() { stdin, stdout, stderr := os.Stdin, os.Stdout, os.Stderr args := os.Args[1:] if len(args) < 1 { - _, _ = fmt.Fprintln(stderr, "usage: envc [flags]") + _, _ = fmt.Fprintln(stderr, "usage: envc [flags]") os.Exit(2) } var code int @@ -20,6 +28,8 @@ func main() { code = RunGet(args[1:], stdin, stdout, stderr) case "merge": code = RunMerge(args[1:], stdin, stdout, stderr) + case "version", "--version", "-v": + _, _ = fmt.Fprintf(stdout, "envc %s (commit %s, built %s)\n", version, commit, date) default: _, _ = fmt.Fprintf(stderr, "unknown command %q\n", args[0]) code = 2 diff --git a/docs/articles/.~lock.motivation.md# b/docs/articles/.~lock.motivation.md# new file mode 100644 index 0000000..4933618 --- /dev/null +++ b/docs/articles/.~lock.motivation.md# @@ -0,0 +1 @@ +,ano,anoga,02.05.2026 18:15,/home/ano/.local/share/onlyoffice; \ No newline at end of file diff --git a/docs/articles/motivation.md b/docs/articles/motivation.md new file mode 100644 index 0000000..8a90357 --- /dev/null +++ b/docs/articles/motivation.md @@ -0,0 +1,179 @@ +# Go config: many good pieces, no whole picture + +*Why I keep three different config libraries open in three different services — and what a "unified" tool would actually have to do.* + +--- + +I have been writing Go services for years and I still do not have one obvious answer to: *"how does this `config.yaml`, that `.env`, the process environment, and my nested `Config` struct stay one consistent story across dev, staging, and prod?"* + +The Go ecosystem has plenty of config libraries. Most of them are good at their slice. Almost none of them advertise honestly which slice that is, and the gap shows the moment your `Config` is more than three flat fields and your sources are more than one file. + +This is not a benchmark shoot-out. It is a map of where the seams are, what the popular libraries actually own, and a pattern — *"map in the middle"* — that keeps reappearing in every working setup I have shipped, regardless of which library is on top. + +--- + +## The problem in one scene + +You clone a repo. There is a `config.yaml` for local dev, a `.env.example` nobody copies correctly, and production sets secrets through Kubernetes or systemd. Somewhere in code: + +```go +type Config struct { + HTTP struct { + Listen string + TLS bool + } + Database struct { + URL string + } +} +``` + +Compose says `http.listen`. Kubernetes sets `HTTP_LISTEN`. Your teammate adds `database.url` in YAML, but the managed-Postgres vendor's docs say `DATABASE_URL`, so `.env` uses that. + +None of these choices is wrong. They are how humans write configuration. The pain starts when one binary has to accept all of them, decide *who wins*, and decode into one typed struct without 40 lines of `os.Getenv` glued together with manual `strconv` calls. + +That is the moment you notice "configuration management" is three jobs sold as one: + +1. Parsing a format (YAML, JSON, INI, dotenv). +2. Composing several sources with predictable precedence. +3. Bridging a `map[string]any` into a typed struct, with weak-typing and tag rules. + +A handful of services need only one of those. Most production services need at least two. Nested structs are where the seams between them tear. + +--- + +## What "unified" should mean + +If "unified" is going to mean anything testable, here is the checklist I use: + +- **Parse common formats** — Ops live in YAML, secrets live in env, legacy config still lives in INI. +- **Merge layers** — Defaults < repo config < local overrides < process env (the [12-factor](https://12factor.net/config) ordering). That top layer is how Docker, Kubernetes (ConfigMaps, Secrets, `env` / `envFrom`), systemd, CI, and similar inject values into the running process: the variables are there even when nothing on disk matches prod. +- **Normalize keys** — `sub-service`, `sub_service`, and `SUB_SERVICE` should not each need their own struct tag. +- **Decode to structs** — The end state is a typed `Config`, not a `map`. +- **Encode back** — Dump effective config for a support ticket; generate a starter `.env` from defaults. +- **Operational CLI** — SREs convert and inspect config without `go run ./cmd/debug-config`. +- **Testability** — No package-level globals; deterministic fixtures; merge rules covered by unit tests. + +A library can win two items and stay out of scope for the rest. That is fine. The frustration is when the README says "all your config needs" and the **stress test** — nested structs, multiple files, and process env in the same binary — is left to the reader. + +--- + +## The landscape + +The Go ecosystem is not short of options. [Awesome Go — Configuration](https://awesome-go.com/configuration/) lists dozens. The interesting question is which items on the checklist each tool optimises for. + +### Viper + +[Viper](https://github.com/spf13/viper) is what most Go developers reach for first. It reads files, env, flags, and remote stores; `Unmarshal` decodes into a struct via [mapstructure](https://github.com/go-viper/mapstructure). The mindshare is enormous, which means error messages are searchable and tutorials exist. + +The honest cost is the env story. Binding nested keys from environment variables — `AutomaticEnv` plus `SetEnvKeyReplacer` plus per-leaf `BindEnv` — is the source of recurring bug reports for years; see for instance [#641](https://github.com/spf13/viper/issues/641) and [#2001](https://github.com/spf13/viper/issues/2001). Those issues are not "Viper is broken"; they are evidence that flattening trees into env tokens and decoding back into trees is a hard problem that Viper solves under conventions you have to learn. + +I still pick Viper when a team has already standardised on it and the config shape is shallow. I avoid it for greenfield code where the env layout is non-trivial. + +### Koanf + +[Koanf](https://github.com/knadh/koanf) is the lighter, more composable alternative: separate **providers** (where bytes come from) and **parsers** (how to read them), explicit merge order, no package-level singleton. The mental model is closer to how I think about config anyway, which makes it easier to reason about *which layer won*. + +The trade-off is honest: key normalisation, dotenv quoting edge cases, and struct-decoding policy are choices Koanf will not pre-decide for you. That is the price of the pipeline being explicit, and for a greenfield service I usually want it. + +### Env-first struct loaders + +[caarlos0/env](https://github.com/caarlos0/env), [kelseyhightower/envconfig](https://github.com/kelseyhightower/envconfig), and [cleanenv](https://github.com/ilyakaznacheev/cleanenv) shine when the source of truth is environment variables and the goal is a typed struct with a few validators and defaults. Tiny surface area, sane parsing of durations and lists, no global state. + +What they are not trying to be is a YAML merge engine. If half your config is repo YAML and half is platform env, you will still hand-roll the parser, the merge, and the precedence yourself. + +### Dotenv + +[joho/godotenv](https://github.com/joho/godotenv) parses `.env` files: quoting, exports, expansion. That is the entire job, and it does it well. It is a building block, not a config story — pair it with a struct loader and a merge policy and you have something usable. + +### Format-only parsers + +The standard library's [`encoding/json`](https://pkg.go.dev/encoding/json), [`gopkg.in/yaml.v3`](https://github.com/go-yaml/yaml), and an INI reader of your choice each turn bytes into a value. They do not say how three files plus process env become one tree. "We use `encoding/json` for config" is fine for one file; it leaves merge order, env overrides, and key aliasing as application policy duplicated across every repo. + +### mapstructure + +[go-viper/mapstructure](https://github.com/go-viper/mapstructure) — the v2 line, forked from the (now archived) `mitchellh/mapstructure` and maintained inside the Viper org — is the de facto bridge from `map[string]any` into structs. Tags, `WeaklyTypedInput`, decode hooks, `squash` for embedded fields, time and net.IP parsing. + +It is *not* a format parser. It does not define merge precedence. It does not decide whether `SUB_SERVICE_NAME` lines up with `SubService.Name` until you give it a map where those keys already match. Almost every working setup ends up using mapstructure as a stage, even if the outer library is Viper, Koanf, or hand-written glue. + +--- + +## Why nested and embedded structs are the real stress test + +Flat structs are easy. The pain shows up here: + +**Embedding.** Go pushes composition via embedded types and `Config` structs follow. The moment you embed, you have to decide: are the embedded type's keys *flattened* into the outer namespace, or *namespaced* under a sub-key? Mapstructure's `squash` exists for exactly this question, but it only helps once the merged map already matches whichever choice you made. Two teammates can disagree by accident and the bug looks like "this field is silently empty". + +**Multiple tag vocabularies.** `json:"..."` for the wire, `yaml:"..."` for files, `mapstructure:"..."` for the generic decoder, sometimes `env:"..."` on top. This is not a Go failure; it is several audiences (API clients, operators, decoder) sharing one struct. Without a convention, you end up with fields that decode from one format and silently miss in another. + +**Slices and maps in env.** Environment variables are strings. Lists become comma-separated strings, JSON-in-a-string, or repeated keys with an index suffix — every library picks a different convention. Pick one team-wide and document it; otherwise you spend Mondays explaining why `FEATURES=a,b,c` produced a `[]string{"a,b,c"}`. + +**Weak typing.** `encoding/json` decodes every JSON number into `float64` when the destination is `any`; YAML 1.1 happily turns `no` into a boolean `false`; envs are always strings until somebody coerces them. mapstructure's `WeaklyTypedInput` and decode hooks paper over a lot of this — but only on the typed-struct side, not when you read a value out of a generic map. + +**Pointer vs value.** Optional sub-trees are often `*Section`. Merge policy and decode policy must agree on whether a missing key sets the pointer to `nil`, leaves it at the previous value, or constructs an empty `&Section{}`. The case you forget is the one staging hits at 2 a.m. + +**JSON-in-an-env-var.** It works until shell quoting eats a `"`, or your log aggregator truncates the line, or somebody rotates a secret and reformats it. It is a perfectly fine escape hatch and a terrible default. + +If a library makes flat env parsing delightful but treats nested trees as second-class, it is not failing — it is optimising for a different shape. My services keep landing in the nested zone, which is why I keep coming back to the same pattern. + +--- + +## The missing middle: "map in the middle" + +Once you separate the three jobs above, the same shape keeps showing up: + +```text +bytes → nested map[string]any → mapstructure → struct +``` + +Parsers give you the left arrow. Mapstructure gives you the right arrow. The middle arrow — canonical nested maps, deep merge semantics, key normalisation — is where every team I have worked with reinvents the same private `deepMerge` and the same `strings.NewReplacer("-", "_")` helper. + +Most libraries do *some* version of the middle arrow internally; few document it as the primary mental model for *all* formats. Once you do, three things get easier at once: + +- **Cross-format equivalence becomes one function.** If `sub-service` and `SUB_SERVICE` collapse to the same path component before decoding, you stop maintaining parallel tag systems for the same field. +- **Tests improve.** You can snapshot the merged map before struct decode and bisect "parser bug" vs "tag bug" without reading files from disk in every case. +- **CLI tooling becomes cheap.** A `convert` subcommand is just *parse → map → re-encode*. A `merge` subcommand is *parse N → fold → re-encode*. No second framework needed. + +This is not a call to rewrite everything in `map[string]any`. It is a call to *name the pipeline* so teams can argue about merge rules and key normalisation in the same vocabulary they already use for JSON and YAML. + +--- + +## Closing: own the layer you actually use + +There is good tooling in Go. What I have not found is a **default** that a new teammate can assume without reading the wiki, because the right answer depends on which items on the checklist your service is centred on. + +Two practical recommendations from years of doing this wrong first: + +1. **Pick a center of gravity, then accept the edges.** If your service is twelve-factor and env-first, an env struct loader is the whole story; do not bolt on YAML "just in case". If your service is YAML-first with a few env overrides, Viper or Koanf is fine — but write down the env-key flattening rule on day one, not after the first incident. +2. **Make the intermediate map explicit.** If you keep hitting merge-and-normalise bugs across formats, stop hiding the `map[string]any` step inside ad-hoc helpers. A single deep-merge function and a single key normaliser, both unit-tested, are worth more than any new dependency. + +Other ecosystems have made the same observation under their own names — Rust's [Figment](https://docs.rs/figment/latest/figment/), used by Rocket, treats providers and profiles as first-class. The tension between "one source of bytes" and "one typed config" is not language-specific. Go just distributes the answer across more packages. + +### Where `go-config` fits (this repository) + +[go-config](https://github.com/eslider/go-config) is my attempt to optimise for the middle arrow with symmetric codecs across `env`, `yaml`, `json`, and `ini`. Same `Codec` shape per format; deep merge for maps with last-write-wins on scalars; `LowerAlnum` key normaliser so cross-format paths line up before struct tags do the fine work; a small `envc` CLI for `convert / get / merge`. + +The `env` package layers dotenv files and then **`WithCurrentEnvironment()`**, which reads the live process environment (`os.Environ()` at `Map` time) and merges it as the usual highest-priority source—so the same code path covers a laptop `.env` and variables injected into a Docker or Kubernetes container. + +It is *an* answer to the pain described here, not a claim that other libraries are obsolete. For API details see the [README](https://github.com/eSlider/go-config/blob/main/README.md); for the architectural decisions behind it (what is in scope, what is not, what was deliberately broken from `v0.1.0`) see [docs/asr/README.md](https://github.com/eSlider/go-config/blob/main/docs/asr/README.md). + +--- + +## References + +- 12-factor — Config: +- Awesome Go — Configuration: +- Viper: +- Viper issue #641 — nested env binding: +- Viper issue #2001 — nested struct decode from env: +- Koanf: +- caarlos0/env: +- kelseyhightower/envconfig: +- cleanenv: +- joho/godotenv: +- go-viper/mapstructure (v2): +- encoding/json (stdlib): +- gopkg.in/yaml.v3: +- Figment (Rust): +- go-config — README: +- go-config — ASR index: diff --git a/docs/articles/motivation.ru.md b/docs/articles/motivation.ru.md new file mode 100644 index 0000000..327c2af --- /dev/null +++ b/docs/articles/motivation.ru.md @@ -0,0 +1,197 @@ +# Конфиг в Go: библиотек много, «единого решения» нет + +*Что регулярно ломается в реальных сервисах, когда надо совместить YAML, `.env`, переменные окружения и вложенный `Config`.* + +--- + +Вот абсолютно бытовая ситуация. + +Есть `config.yaml` для локалки. Есть `.env.example`, который у каждого чуть свой. В проде значения прилетают через Docker/Kubernetes/systemd. В коде живет нормальный вложенный `Config`, а не плоская простыня. + +И вот в этот момент становится ясно: в Go нет одного «очевидного» инструмента, который без плясок закрывает всю цепочку целиком. + +Это не наезд на экосистему. В ней много сильных библиотек. Проблема в другом: почти каждая решает свой кусок, а швы между кусками остаются на команде. + +--- + +## Проблема в одном примере + +В коде: + +```go +type Config struct { + HTTP struct { + Listen string + TLS bool + } + Database struct { + URL string + } +} +``` + +В `docker-compose.yml` — `http.listen`. +В Kubernetes — `HTTP_LISTEN`. +В YAML кто-то пишет `database.url`. +Провайдер PostgreSQL в документации советует `DATABASE_URL`. + +Все правы. Но бинарнику от этого не легче. + +Ему нужно: + +1. Прочитать разные форматы. +2. Слить источники в понятном порядке приоритетов. +3. Заполнить типизированную структуру без ручного ада из `os.Getenv` и `strconv`. + +Именно на этом месте «конфиг» перестает быть одной задачей и разваливается на три. + +--- + +## Что я называю «единым» решением + +Для себя я держу простой чек-лист: + +- **Парсинг форматов** — YAML/JSON/INI/dotenv. +- **Слои и приоритеты** — defaults < repo config < local override < process env (по логике [12-factor](https://12factor.net/config)). +- **Нормализация ключей** — чтобы `sub-service`, `sub_service` и `SUB_SERVICE` жили в одном мире. +- **Декод в структуры** — конечная цель это `Config`, а не `map`. +- **Обратная кодировка** — уметь вывести эффективный конфиг обратно в файл. +- **CLI для операционки** — чтобы не писать вспомогательные `cmd/*` для каждой мелочи. +- **Тестируемость** — без скрытых глобалов, с фиксированным и проверяемым merge-поведением. + +Важно: это не значит «одна библиотека обязана уметь всё». Нормально, когда инструмент закрывает 2-3 пункта. Ненормально, когда в README обещается «полный цикл», а сложные кейсы остаются «догадайтесь сами». + +--- + +## Кратко по инструментам + +### Viper + +[Viper](https://github.com/spf13/viper) — первый выбор у многих. Большое сообщество, много примеров, привычный API. + +Где боль: вложенные env-ключи и связка `AutomaticEnv` + `SetEnvKeyReplacer` + `BindEnv`. Проблема известная и давняя, это видно по тредам вроде [#641](https://github.com/spf13/viper/issues/641) и [#2001](https://github.com/spf13/viper/issues/2001). + +Итог: рабочий вариант, особенно если команда уже на нем. Но с вложенным конфигом и сложным env-layout нужна дисциплина. + +### Koanf + +[Koanf](https://github.com/knadh/koanf) обычно воспринимается как более аккуратная композиция: providers, parsers, явный merge-порядок. + +Плюс: пайплайн прозрачен. +Минус: часть решений все равно на вас (нормализация ключей, соглашения по env, стратегия декода). + +### Env-first библиотеки + +[caarlos0/env](https://github.com/caarlos0/env), [envconfig](https://github.com/kelseyhightower/envconfig), [cleanenv](https://github.com/ilyakaznacheev/cleanenv) отлично подходят, когда источник истины — env, а задача — быстро собрать типизированный `Config`. + +Если же у вас YAML + env + несколько слоев, они не дадут весь конвейер «из коробки». Нужен клей. + +### Dotenv-парсеры + +[joho/godotenv](https://github.com/joho/godotenv) делает ровно то, что заявлено: корректно читает `.env`. + +Это хороший кирпич. Но не целый дом. + +### «Просто парсеры» + +[encoding/json](https://pkg.go.dev/encoding/json), [gopkg.in/yaml.v3](https://github.com/go-yaml/yaml), INI-библиотеки — хорошие парсеры. + +Но они не решают сами по себе: +- порядок слоев, +- env-override, +- нормализацию имен ключей между форматами. + +### mapstructure + +[go-viper/mapstructure](https://github.com/go-viper/mapstructure) (v2) — по факту стандартный мост из `map[string]any` в структуру. + +Это не парсер и не merge-движок. Его задача — декод. +Поэтому без аккуратной «середины» (о ней ниже) магии не будет. + +--- + +## Почему вложенные структуры — главный тест + +На плоском конфиге почти всё выглядит красиво. Проблемы приходят, когда структура становится реальной: + +- **Embedding / `squash`**: где-то поля должны «подниматься», где-то жить в поддереве. +- **Несколько тегов на одно поле**: `json`, `yaml`, `mapstructure`, иногда `env`. +- **Списки в env**: `a,b,c`, JSON-строка, индексные ключи — у всех свои правила. +- **Слабая типизация**: особенно заметно на стыке `any`, `float64`, yaml-особенностей и env-строк. +- **`*Section` vs value**: отсутствие ключа, пустое значение и `nil` — не одно и то же. +- **«JSON в env»**: рабочий костыль, но часто больной в эксплуатации (кавычки, экранирование, логирование). + +Если библиотека шикарна на плоском env, но сыпется на вложенных деревьях — это не «плохая библиотека». Просто ее зона оптимизации другая. + +--- + +## Недостающая середина: map в центре пайплайна + +Практически везде рабочая схема выглядит так: + +```text +bytes -> nested map[string]any -> mapstructure -> struct +``` + +Левая часть — чтение источников. +Правая — декод в структуру. +А вот середину (merge + нормализация ключей) команды часто собирают сами и по-разному. + +Почему это важно явно оформить: + +- **Одна точка для кросс-форматной эквивалентности** (`sub-service` == `SUB_SERVICE` после нормализации). +- **Предсказуемые тесты** (можно проверять merged-map до декода). +- **Простой CLI** (`convert`, `merge`, `get` — это по сути операции вокруг той же map). + +Это не призыв «всё переписать на map». Это призыв честно назвать центральный этап, от которого зависит поведение всей системы. + +--- + +## Практический вывод + +Универсальной «серебряной пули» нет. +Есть осознанный выбор того, **каким слоем вы управляете сами**, а что делегируете библиотеке. + +Два правила, которые реально экономят время: + +1. **Сразу зафиксируйте модель приоритетов и именования ключей.** Не «когда начнет гореть», а в первый день. +2. **Сделайте merge + normalizer отдельным, тестируемым слоем.** Это чаще окупается сильнее, чем замена одной библиотеки на другую. + +--- + +## Где здесь `go-config` + +[go-config](https://github.com/eslider/go-config) — попытка сделать именно эту «середину» предсказуемой: + +- одинаковый `Codec`-подход для `env`, `yaml`, `json`, `ini`; +- deep merge для map и last-write-wins для скаляров; +- нормализация ключей через `LowerAlnum`; +- CLI `envc` для `convert / get / merge`. + +Для контейнеров отдельный плюс: пакет `env` умеет слоить dotenv-файлы и затем применять **`WithCurrentEnvironment()`**. Это берет текущее окружение процесса (`os.Environ()` на момент `Map`) как верхний слой, поэтому одна и та же схема работает и локально, и в Docker/Kubernetes. + +Это не «единственно правильный путь». Это одна из рабочих реализаций подхода, описанного выше. + +Подробности API — в [README](https://github.com/eSlider/go-config/blob/main/README.md). +Архитектурные решения — в [ASR](https://github.com/eSlider/go-config/blob/main/docs/asr/README.md). + +--- + +## Ссылки + +- 12-factor — Config: +- Awesome Go — Configuration: +- Viper: +- Viper issue #641 — nested env binding: +- Viper issue #2001 — nested struct decode from env: +- Koanf: +- caarlos0/env: +- kelseyhightower/envconfig: +- cleanenv: +- joho/godotenv: +- go-viper/mapstructure (v2): +- encoding/json (stdlib): +- gopkg.in/yaml.v3: +- Figment (Rust): +- go-config — README: +- go-config — ASR index: diff --git a/docs/asr/ASR-0009-ci-cd-and-automated-semver.md b/docs/asr/ASR-0009-ci-cd-and-automated-semver.md new file mode 100644 index 0000000..ce12896 --- /dev/null +++ b/docs/asr/ASR-0009-ci-cd-and-automated-semver.md @@ -0,0 +1,65 @@ +# ASR-0009: CI/CD pipeline and automated semantic versioning + +## Context + +ASR-0008 only covered GitHub presentation and the initial manual tag of `v0.1.0`. +The project needs continuous verification on every PR and a release flow that does +not require humans to bump version strings. The source tree must stay free of +version literals (the CHANGELOG aside) so that `go install …@latest` and GoReleaser +artefacts agree on a single source of truth: **git tags**. + +## Decision + +1. **Test pipeline** (`.github/workflows/test.yml`): matrix of Go `1.22`, `1.23`, + and `stable` on `ubuntu-latest` plus one `stable` run on macOS and Windows, + with `go test -race -shuffle=on -count=1 -coverprofile=coverage.out`. Coverage + is uploaded to Codecov from the Linux `stable` leg only. `go mod tidy` is + enforced via `git diff --exit-code`. +2. **Lint pipeline** (`.github/workflows/lint.yml`): `go vet` plus + `golangci-lint@v6` using the repo's existing `.golangci.yml`. +3. **Automated SemVer** (`.github/workflows/release-please.yml` + + `release-please-config.json` + `.release-please-manifest.json`): Google's + [release-please-action](https://github.com/googleapis/release-please-action) + in `release-type: go` mode parses [Conventional + Commits](https://www.conventionalcommits.org/) since the previous tag and + opens a "chore: release vX.Y.Z" PR that edits `CHANGELOG.md` and the + manifest only. Merging the PR creates the git tag. The manifest is seeded + with the current `0.2.0` so history is continuous. +4. **Release pipeline** (`.github/workflows/release.yml` + `.goreleaser.yml`): + on `push: tags: ["v*"]`, GoReleaser v2 cross-compiles `cmd/envc` for + linux/darwin/windows × amd64/arm64 (no windows/arm64), stamps + `-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}` + into the binary, produces `tar.gz` / `zip` archives plus `checksums.txt`, + and attaches them to a GitHub Release whose body is generated from commit + groups (`feat`, `fix`, `perf`, …). +5. **No version literals in Go code.** `cmd/envc/main.go` defines + `var version = "dev"` (and `commit`, `date`), overridable only at link time. + `envc version` prints whatever the release build injected; local + `go install` builds report `dev`. +6. **Dependency hygiene** (`.github/dependabot.yml`): weekly `gomod` and + `github-actions` updates, grouped for minor/patch, with conventional + `chore(deps)` / `ci(actions)` commit prefixes so release-please classifies + them correctly. + +## Consequences + +- Contributors must use Conventional Commits; otherwise release-please will not + bump the version. `chore:` / `docs:` / `ci:` commits produce no release. +- Breaking changes require `feat!:` or a `BREAKING CHANGE:` footer, which + release-please maps to a major bump (subject to `bump-minor-pre-major: true` + while the module is pre-1.0). +- Re-tagging a release manually is discouraged — the tag is the contract that + triggers GoReleaser. +- Codecov token (`CODECOV_TOKEN`) and branch protection enforcing the Tests + and Lint checks must be configured in the GitHub repository settings + (operational, not code). + +## Status + +Accepted — 2026-05-02. + +## References + +- [release-please-action](https://github.com/googleapis/release-please-action) +- [GoReleaser v2 docs](https://goreleaser.com/customization/) +- [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) diff --git a/docs/asr/README.md b/docs/asr/README.md index ccd2eed..b2b0535 100644 --- a/docs/asr/README.md +++ b/docs/asr/README.md @@ -12,3 +12,4 @@ Repo-local decisions for `go-config` (numbering starts at ASR-0001). Org-wide po | [ASR-0006](ASR-0006-third-party-library-choices.md) | Third-party library choices | | [ASR-0007](ASR-0007-cli-envc-convert-get-merge.md) | CLI `envc`: convert, get, merge | | [ASR-0008](ASR-0008-github-presentation-and-release-flow.md) | GitHub presentation and release flow | +| [ASR-0009](ASR-0009-ci-cd-and-automated-semver.md) | CI/CD pipeline and automated semantic versioning | diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..47dd786 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "go", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "include-v-in-tag": true, + "include-component-in-tag": false, + "pull-request-title-pattern": "chore${scope}: release ${version}", + "changelog-path": "CHANGELOG.md", + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "revert", "section": "Reverts" }, + { "type": "refactor", "section": "Code Refactoring" }, + { "type": "docs", "section": "Documentation" }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "Continuous Integration", "hidden": true }, + { "type": "chore", "section": "Miscellaneous", "hidden": true }, + { "type": "style", "section": "Styles", "hidden": true } + ], + "packages": { + ".": { + "package-name": "go-config" + } + } +}