From cf82633ab78a8b237fdb7ac0e092e60b4fc90ff4 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 31 Jul 2026 19:13:45 +0000 Subject: [PATCH] Harden release publication plumbing --- .woodpecker/release.yml | 51 +++++++++++++++++++++++++++++++-- docs/cli.md | 5 ++++ docs/internal/cli.md | 4 +++ internal/buildinfo/buildinfo.go | 6 ++++ internal/cli/root.go | 15 ++++++++++ internal/cli/root_test.go | 25 ++++++++++++++++ 6 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 internal/buildinfo/buildinfo.go diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index 0a9f759..fa5337e 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -2,8 +2,50 @@ when: - event: tag steps: + - name: validate-release + image: golang:1.26.5 + commands: + - | + set -eu + + version="$CI_COMMIT_TAG" + release_note="docs/releases/$version.md" + + if ! printf '%s\n' "$version" | + grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + then + printf '%s\n' "invalid release tag: $version" >&2 + exit 1 + fi + test -s "$release_note" + test -z "$(git ls-files go.work go.work.sum)" + test ! -e vendor + if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod + then + printf '%s\n' 'go.mod contains a replacement' >&2 + exit 1 + fi + + GOWORK=off go test -count=1 ./... + GOWORK=off go test -race -count=1 ./... + GOWORK=off go vet ./... + GOWORK=off go build ./... + GOWORK=off go mod tidy -diff + + unformatted=$( + git ls-files '*.go' | + while IFS= read -r go_file + do + gofmt -l "$go_file" + done + ) + test -z "$unformatted" + git diff --check + - name: build-release-assets - image: golang:1.25 + image: golang:1.26.5 + depends_on: + - validate-release commands: - | set -eu @@ -33,8 +75,11 @@ steps: build_binary windows amd64 ".exe" build_binary windows arm64 ".exe" + host_binary="$dist/weatherreporter-$version-$(go env GOOS)-$(go env GOARCH)" + test "$("$host_binary" --version)" = "weatherreporter $version" + - name: publish-release - image: woodpeckerci/plugin-release + image: woodpeckerci/plugin-release:0.3.1 depends_on: - build-release-assets settings: @@ -42,6 +87,8 @@ steps: from_secret: GITEA_RELEASE_TOKEN files: - dist/weatherreporter-* + title: Weatherreporter ${CI_COMMIT_TAG} + note: docs/releases/${CI_COMMIT_TAG}.md checksum: sha256 checksum-file: SHA256SUMS checksum-flatten: true diff --git a/docs/cli.md b/docs/cli.md index 6279d03..321c439 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -17,6 +17,7 @@ required Weather API endpoint. ```text weatherreporter --help +weatherreporter --version weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet] weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--llm-debug-dir PATH] [--quiet] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet] @@ -31,6 +32,10 @@ weatherreporter inspect prior [--config PATH] RUN_ID weatherreporter inspect sources [--config PATH] RUN_ID ``` +`weatherreporter --version` prints the version embedded in the executable. +Tagged release binaries report their semantic version tag; ordinary local +builds report `development`. + | Command | Contract | | --- | --- | | `generate daily` | Requires `--date YYYY-MM-DD`; the date is interpreted in the effective report timezone. | diff --git a/docs/internal/cli.md b/docs/internal/cli.md index 4f4d62b..611cd10 100644 --- a/docs/internal/cli.md +++ b/docs/internal/cli.md @@ -4,6 +4,10 @@ requests, and translates app results to bounded JSON summaries. The user contract belongs in the [CLI reference](../cli.md). +The root `--version` flag reports the build version supplied by +`internal/buildinfo`. Tagged release builds replace its development default at +link time. + For each `generate` or `run` action, `Runner` constructs one project-owned Promptkit executor after configuration loads. It passes the executor and any `--llm-debug-dir` request into the app. `run` accepts the debug flag as well diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..7ab07db --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,6 @@ +// Package buildinfo exposes release metadata injected by the build pipeline. +package buildinfo + +// Version identifies this Weatherreporter build. Release builds replace the +// development value with their semantic version tag through the Go linker. +var Version = "development" diff --git a/internal/cli/root.go b/internal/cli/root.go index 3902599..f964f6b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -7,6 +7,7 @@ import ( "io" "gitea.maximumdirect.net/eric/weatherreporter/internal/app" + "gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" @@ -16,6 +17,7 @@ const helpText = `weatherreporter prepares weather reports from normalized forec Usage: weatherreporter --help + weatherreporter --version weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet] weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--llm-debug-dir PATH] [--quiet] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet] @@ -31,6 +33,7 @@ Usage: Options: -h, --help Show this help message. + --version Show the Weatherreporter version. --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml. --units VALUE Override weather API units. --tz NAME Override weather API timezone. @@ -43,6 +46,7 @@ Options: type Runner struct { Clock timeutil.Clock ExecutorFactory ExecutorFactory + Version string } func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { @@ -57,6 +61,17 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr _, err := fmt.Fprint(stdout, helpText) return err } + if args[0] == "--version" { + if len(args) != 1 { + return fmt.Errorf("--version does not accept arguments") + } + version := r.Version + if version == "" { + version = buildinfo.Version + } + _, err := fmt.Fprintf(stdout, "weatherreporter %s\n", version) + return err + } switch args[0] { case "generate": diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 32ad672..b551521 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -88,6 +88,7 @@ func TestRunnerHelpListsOnlySupportedCommands(t *testing.T) { t.Fatalf("Run(--help) error = %v", err) } for _, command := range []string{ + "--version", "generate daily", "generate today", "generate tomorrow", "generate hourly", "run morning", "run evening", "inspect reports", "inspect metadata", "inspect modules", "inspect data-package", "inspect prior", "inspect sources", } { @@ -102,6 +103,30 @@ func TestRunnerHelpListsOnlySupportedCommands(t *testing.T) { } } +func TestRunnerVersion(t *testing.T) { + for _, test := range []struct { + name string + runner Runner + version string + }{ + {name: "development default", runner: Runner{}, version: "development"}, + {name: "injected release", runner: Runner{Version: "v0.9.0-test"}, version: "v0.9.0-test"}, + } { + t.Run(test.name, func(t *testing.T) { + output, err := runCLICommand(test.runner, "--version") + if err != nil { + t.Fatalf("Run(--version) error = %v", err) + } + if output.stdout != "weatherreporter "+test.version+"\n" || output.stderr != "" { + t.Fatalf("Run(--version) output = stdout %q stderr %q", output.stdout, output.stderr) + } + }) + } + if _, err := runCLICommand(Runner{Version: "v0.9.0-test"}, "--version", "extra"); err == nil { + t.Fatal("Run(--version extra) error = nil") + } +} + func TestResolveSupportedCommandsAndFlags(t *testing.T) { configPath := writeCLIConfig(t, t.TempDir(), "") runner, constructions := countingRunner(cliExecutor{})