docs: enhance release flow documentation and add version command to CLI

- Updated AGENTS.md and README.md to clarify the automated release process using release-please and GoReleaser.
- Added a version command to the envc CLI to display build metadata.
- Included CI/CD details in README for better understanding of testing and release automation.
This commit is contained in:
2026-05-02 20:00:14 +01:00
parent 43f4b2dc96
commit 0ae52ef72d
11 changed files with 615 additions and 4 deletions
+94
View File
@@ -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 }}
+3
View File
@@ -0,0 +1,3 @@
{
".": "0.2.0"
}
+12 -3
View File
@@ -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
+24
View File
@@ -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).
+11 -1
View File
@@ -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 <convert|get|merge> [flags]")
_, _ = fmt.Fprintln(stderr, "usage: envc <convert|get|merge|version> [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
+1
View File
@@ -0,0 +1 @@
,ano,anoga,02.05.2026 18:15,/home/ano/.local/share/onlyoffice;
+179
View File
@@ -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: <https://12factor.net/config>
- Awesome Go — Configuration: <https://awesome-go.com/configuration/>
- Viper: <https://github.com/spf13/viper>
- Viper issue #641 — nested env binding: <https://github.com/spf13/viper/issues/641>
- Viper issue #2001 — nested struct decode from env: <https://github.com/spf13/viper/issues/2001>
- Koanf: <https://github.com/knadh/koanf>
- caarlos0/env: <https://github.com/caarlos0/env>
- kelseyhightower/envconfig: <https://github.com/kelseyhightower/envconfig>
- cleanenv: <https://github.com/ilyakaznacheev/cleanenv>
- joho/godotenv: <https://github.com/joho/godotenv>
- go-viper/mapstructure (v2): <https://github.com/go-viper/mapstructure>
- encoding/json (stdlib): <https://pkg.go.dev/encoding/json>
- gopkg.in/yaml.v3: <https://github.com/go-yaml/yaml>
- Figment (Rust): <https://docs.rs/figment/latest/figment/>
- go-config — README: <https://github.com/eSlider/go-config/blob/main/README.md>
- go-config — ASR index: <https://github.com/eSlider/go-config/blob/main/docs/asr/README.md>
+197
View File
@@ -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: <https://12factor.net/config>
- Awesome Go — Configuration: <https://awesome-go.com/configuration/>
- Viper: <https://github.com/spf13/viper>
- Viper issue #641 — nested env binding: <https://github.com/spf13/viper/issues/641>
- Viper issue #2001 — nested struct decode from env: <https://github.com/spf13/viper/issues/2001>
- Koanf: <https://github.com/knadh/koanf>
- caarlos0/env: <https://github.com/caarlos0/env>
- kelseyhightower/envconfig: <https://github.com/kelseyhightower/envconfig>
- cleanenv: <https://github.com/ilyakaznacheev/cleanenv>
- joho/godotenv: <https://github.com/joho/godotenv>
- go-viper/mapstructure (v2): <https://github.com/go-viper/mapstructure>
- encoding/json (stdlib): <https://pkg.go.dev/encoding/json>
- gopkg.in/yaml.v3: <https://github.com/go-yaml/yaml>
- Figment (Rust): <https://docs.rs/figment/latest/figment/>
- go-config — README: <https://github.com/eSlider/go-config/blob/main/README.md>
- go-config — ASR index: <https://github.com/eSlider/go-config/blob/main/docs/asr/README.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/)
+1
View File
@@ -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 |
+28
View File
@@ -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"
}
}
}