From 94e41fe5b55df3f532316c1d3203652e53746860 Mon Sep 17 00:00:00 2001 From: Gary Hansen Date: Tue, 7 Jul 2026 23:31:34 +1000 Subject: [PATCH] feat(release): version stamping and tag-triggered release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --version/-V on the CLI, version in /api/health via Server.SetVersion, Makefile/Dockerfile ldflags stamping from git describe, and a release workflow on v* tags: both binaries for linux/darwin (amd64+arm64) and windows/amd64 with SHA256SUMS attached to the Gitea release (idempotent — reuses an existing hand-written release and skips already-uploaded assets), plus version-tagged Docker images. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/release.yml | 132 +++++++++++++++++++++++++++++++++++ Dockerfile.cli | 3 +- Dockerfile.web | 3 +- Makefile | 6 +- cmd/exploredns/main.go | 14 ++++ cmd/server/main.go | 6 +- internal/config/config.go | 3 + web/api/server.go | 18 +++-- 8 files changed, 176 insertions(+), 9 deletions(-) create mode 100644 .gitea/workflows/release.yml diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..c322e0a --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,132 @@ +name: Release + +# Builds release artifacts when a version tag (v*) is pushed: +# - cross-compiled CLI + server binaries attached to the Gitea release +# - version-tagged docker images for the CLI and web server +# The owner usually creates the Gitea release by hand with notes; this +# workflow attaches assets to it (creating a bare release only when none +# exists) and skips assets that are already attached, so re-runs are safe. + +on: + push: + tags: ["v*"] + +jobs: + binaries: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: Build release binaries + env: + GOTOOLCHAIN: local + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + mkdir -p dist + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do + GOOS="${target%/*}" + GOARCH="${target#*/}" + EXT="" + [ "$GOOS" = "windows" ] && EXT=".exe" + OUT="dist/${GOOS}_${GOARCH}" + mkdir -p "$OUT" + CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \ + go build -trimpath -ldflags "-s -w -X main.version=${TAG}" \ + -o "${OUT}/exploredns${EXT}" ./cmd/exploredns + CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \ + go build -trimpath -ldflags "-s -w -X main.version=${TAG}" \ + -o "${OUT}/exploredns-server${EXT}" ./cmd/server + if [ "$GOOS" = "windows" ]; then + (cd "$OUT" && zip -q "../exploredns_${TAG}_${GOOS}_${GOARCH}.zip" exploredns.exe exploredns-server.exe) + else + tar -czf "dist/exploredns_${TAG}_${GOOS}_${GOARCH}.tar.gz" -C "$OUT" exploredns exploredns-server + fi + rm -rf "$OUT" + done + (cd dist && sha256sum -- * > SHA256SUMS) + ls -l dist + + - name: Attach assets to Gitea release + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + API: ${{ github.server_url }}/api/v1 + REPO: ${{ github.repository }} + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + AUTH="Authorization: token ${TOKEN}" + + # Look up the release for this tag; create a bare one only when + # none exists (the owner writes release notes by hand). + STATUS=$(curl -sS -o release.json -w '%{http_code}' -H "$AUTH" \ + "${API}/repos/${REPO}/releases/tags/${TAG}") + if [ "$STATUS" = "404" ]; then + curl -sS -f -o release.json -H "$AUTH" -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\"}" \ + "${API}/repos/${REPO}/releases" + elif [ "$STATUS" != "200" ]; then + echo "release lookup failed with HTTP ${STATUS}" >&2 + cat release.json >&2 + exit 1 + fi + RELEASE_ID=$(jq -r '.id' release.json) + echo "release id: ${RELEASE_ID}" + + # Existing asset names, so re-runs skip instead of failing. + curl -sS -f -H "$AUTH" \ + "${API}/repos/${REPO}/releases/${RELEASE_ID}/assets" \ + | jq -r '.[].name' > existing.txt + + for f in dist/*; do + NAME=$(basename "$f") + if grep -Fxq "$NAME" existing.txt; then + echo "skip ${NAME} (already attached)" + continue + fi + echo "upload ${NAME}" + curl -sS -f -o /dev/null -H "$AUTH" \ + -F "attachment=@${f}" \ + "${API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${NAME}" + done + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: gitea.hansenits.com.au + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push CLI image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.cli + push: true + build-args: | + VERSION=${{ github.ref_name }} + tags: | + gitea.hansenits.com.au/hits/exploredns-cli:latest + gitea.hansenits.com.au/hits/exploredns-cli:${{ github.ref_name }} + + - name: Build and push web image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.web + push: true + build-args: | + VERSION=${{ github.ref_name }} + tags: | + gitea.hansenits.com.au/hits/exploredns-web:latest + gitea.hansenits.com.au/hits/exploredns-web:${{ github.ref_name }} diff --git a/Dockerfile.cli b/Dockerfile.cli index 14a05c2..fd40a4c 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -8,7 +8,8 @@ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/exploredns ./cmd/exploredns +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/exploredns ./cmd/exploredns # Final stage FROM alpine:3.21 diff --git a/Dockerfile.web b/Dockerfile.web index 5e6ec62..7636194 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -8,7 +8,8 @@ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/server ./cmd/server # Final stage FROM alpine:3.21 diff --git a/Makefile b/Makefile index 8887fe4..d73dcb0 100644 --- a/Makefile +++ b/Makefile @@ -3,14 +3,16 @@ SERVER_BINARY_NAME=exploredns-server BUILD_DIR=bin GO=go GOFLAGS=-v +VERSION?=$(shell git describe --tags --always 2>/dev/null || echo dev) +LDFLAGS=-ldflags "-X main.version=$(VERSION)" .PHONY: build build-server build-all test lint clean cover deploy deploy-status build: - $(GO) build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/exploredns + $(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/exploredns build-server: - $(GO) build $(GOFLAGS) -o $(BUILD_DIR)/$(SERVER_BINARY_NAME) ./cmd/server + $(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(SERVER_BINARY_NAME) ./cmd/server build-all: build build-server diff --git a/cmd/exploredns/main.go b/cmd/exploredns/main.go index 90d60a8..c125547 100644 --- a/cmd/exploredns/main.go +++ b/cmd/exploredns/main.go @@ -13,6 +13,9 @@ import ( "gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse" ) +// version is stamped at build time via -ldflags "-X main.version=...". +var version = "dev" + func main() { cfg := config.DefaultConfig() @@ -42,6 +45,12 @@ func main() { flag.BoolVar(&dFlag, "debug", false, "Print debug diagnostics to stderr") flag.BoolVar(&ddFlag, "dd", false, "Like -d plus library-level debug") + // Version: long and short form share the same variable (mirrors + // dnstraverse's -V). + var showVersion bool + flag.BoolVar(&showVersion, "version", false, "Print version and exit") + flag.BoolVar(&showVersion, "V", false, "Print version and exit (shorthand)") + // Quiet: long and short form share the same variable. var quietVal bool flag.BoolVar(&quietVal, "quiet", cfg.Quiet, "Suppress the header block") @@ -69,6 +78,11 @@ func main() { flag.Parse() + if showVersion { + fmt.Printf("exploredns %s\n", version) + return + } + args := flag.Args() cfg.QueryType = *queryType diff --git a/cmd/server/main.go b/cmd/server/main.go index f6f2eb9..dc3ea8a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,17 +13,21 @@ import ( "gitea.hansenits.com.au/hits/ExploreDNS/web/api" ) +// version is stamped at build time via -ldflags "-X main.version=...". +var version = "dev" + func main() { addr := flag.String("addr", ":8080", "listen address (host:port)") flag.Parse() srv := api.NewServer(*addr) + srv.SetVersion(version) if err := srv.Start(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - log.Printf("ExploreDNS API server listening on %s", srv.Addr()) + log.Printf("ExploreDNS API server %s listening on %s", version, srv.Addr()) quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) diff --git a/internal/config/config.go b/internal/config/config.go index 4794f41..c12792f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -167,6 +167,9 @@ func PrintUsage() { {"--show-results", "Show the results (default true)"}, {"--show-summary-results", "Show the summary results (default true)"}, }}, + {"General Options", [][2]string{ + {"--version, -V", "Print version and exit"}, + }}, } for _, group := range flagGroups { diff --git a/web/api/server.go b/web/api/server.go index dec400b..9c3fb4a 100644 --- a/web/api/server.go +++ b/web/api/server.go @@ -27,14 +27,23 @@ var staticFiles embed.FS // Server is the HTTP API server. type Server struct { - addr string - srv *http.Server - cancel context.CancelFunc + addr string + version string + srv *http.Server + cancel context.CancelFunc } // NewServer creates a new Server that listens on addr (e.g. ":8080"). func NewServer(addr string) *Server { - return &Server{addr: addr} + return &Server{addr: addr, version: "dev"} +} + +// SetVersion records the build version reported by GET /api/health. +// Call before Start; empty values are ignored. +func (s *Server) SetVersion(v string) { + if v != "" { + s.version = v + } } // Start builds the HTTP handler, begins listening, and returns when the @@ -44,6 +53,7 @@ func (s *Server) Start() error { ctx, cancel := context.WithCancel(context.Background()) s.cancel = cancel h := newHandler(ctx) + h.version = s.version sub, err := fs.Sub(staticFiles, "static") if err != nil {