feat: add CI/CD pipeline, Makefile, Docker, and goreleaser#18
feat: add CI/CD pipeline, Makefile, Docker, and goreleaser#18
Conversation
- Makefile: build, build-all (5 platforms), test, lint, coverage, install, clean - CI workflow: test + lint + build on push/PR to main - Release workflow: goreleaser on version tags with Homebrew tap publishing - Dockerfile: multi-stage build, non-root user, alpine runtime - goreleaser v2: cross-compilation, archives, checksums, changelog, brew formula - Pin Go 1.26.1 and staticcheck 2026.1 for reproducible builds Closes #8
There was a problem hiding this comment.
Pull request overview
Adds build/release infrastructure for the stackctl Go CLI (under cli/) via a Makefile, CI workflows, Docker image build, and GoReleaser-based release automation (including Homebrew tap publishing).
Changes:
- Add
cli/Makefiletargets for local build/test/lint/coverage/install and cross-compilation. - Add GitHub Actions workflows for CI (test/lint/build) and tag-based releases via GoReleaser.
- Add packaging artifacts: multi-stage
Dockerfile,.dockerignore, and.goreleaser.yml(with Homebrew formula publishing).
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
cli/Makefile |
Defines local build/test/lint/coverage targets and cross-platform builds. |
cli/.gitignore |
Ignores GoReleaser output (dist/) alongside bin/ and coverage output. |
Dockerfile |
Multi-stage build for a minimal non-root runtime image. |
.goreleaser.yml |
GoReleaser v2 config for cross-compilation, archives, checksums, changelog, and Homebrew tap publishing. |
.github/workflows/ci.yml |
CI workflow running tests, lint, and cross-platform builds on push/PR. |
.github/workflows/release.yml |
Release workflow triggering on v* tags and running GoReleaser. |
.dockerignore |
Reduces Docker build context by excluding artifacts and non-build files. |
| build: | ||
| CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY_NAME) . | ||
|
|
||
| build-all: | ||
| @for platform in $(PLATFORMS); do \ | ||
| os=$${platform%/*}; \ | ||
| arch=$${platform#*/}; \ | ||
| output=bin/$(BINARY_NAME)-$${os}-$${arch}; \ | ||
| if [ "$${os}" = "windows" ]; then output=$${output}.exe; fi; \ | ||
| echo "Building $${output}..."; \ | ||
| CGO_ENABLED=0 GOOS=$${os} GOARCH=$${arch} go build -ldflags "$(LDFLAGS)" -o $${output} . || exit 1; \ | ||
| done |
There was a problem hiding this comment.
make build / make build-all write outputs under bin/, but the Makefile never creates that directory. On a clean checkout (including in CI), go build -o bin/... will fail with “no such file or directory”. Create bin/ (e.g., mkdir -p bin) before the build commands (and/or before the loop in build-all).
Ensures 'make build' and 'make build-all' work on a clean checkout where bin/ does not yet exist.
| go tool cover -func=coverage.out | ||
|
|
||
| install: | ||
| go install -ldflags "$(LDFLAGS)" . |
There was a problem hiding this comment.
install builds without CGO_ENABLED=0, so make install may produce a dynamically-linked binary that differs from make build / build-all and from release artifacts (which use CGO_ENABLED=0). Consider setting CGO_ENABLED=0 here as well to keep local installs consistent with CI/release builds.
| go install -ldflags "$(LDFLAGS)" . | |
| CGO_ENABLED=0 go install -ldflags "$(LDFLAGS)" . |
| - uses: actions/setup-go@v5 | ||
| with: | ||
| go-version: '1.26.1' | ||
| cache-dependency-path: cli/go.sum | ||
| - run: make test |
There was a problem hiding this comment.
The Go version is duplicated across jobs (go-version: '1.26.1'). To avoid future drift from cli/go.mod, consider switching to go-version-file: cli/go.mod (and drop the hard-coded version) in each actions/setup-go step.
| - name: Run tests | ||
| working-directory: cli | ||
| run: make test | ||
| - name: Run goreleaser | ||
| uses: goreleaser/goreleaser-action@v6 |
There was a problem hiding this comment.
Release workflow runs tests but skips linting/static analysis. This allows publishing a tagged release that passes tests yet fails go vet/staticcheck (e.g., if the tag is pushed directly). Consider running make lint (and installing staticcheck) in this workflow as well, matching CI’s gating checks.
| # Stage 1: Build | ||
| FROM golang:1.26.1-alpine AS builder | ||
|
|
There was a problem hiding this comment.
For supply-chain/reproducibility, consider pinning the base images by digest (e.g., golang:...@sha256:..., alpine:...@sha256:...) instead of mutable tags. This reduces the risk of builds changing unexpectedly over time.
- Add CGO_ENABLED=0 to make install for consistency with build/release - Switch CI from go-version to go-version-file: cli/go.mod to avoid drift - Add lint step to release workflow to gate tagged releases - Pin Dockerfile base images by digest for reproducibility
| @if command -v staticcheck >/dev/null 2>&1; then \ | ||
| staticcheck ./...; \ | ||
| else \ | ||
| echo "staticcheck not installed, skipping (go install honnef.co/go/tools/cmd/staticcheck@latest)"; \ | ||
| fi |
There was a problem hiding this comment.
lint silently skips staticcheck when it isn't installed, which can lead to local lint results diverging from CI (where a specific staticcheck version is installed). Consider making staticcheck a required dependency for make lint (fail with a clear message) or auto-installing a pinned version via a Makefile variable so the same checks run everywhere.
| @if command -v staticcheck >/dev/null 2>&1; then \ | |
| staticcheck ./...; \ | |
| else \ | |
| echo "staticcheck not installed, skipping (go install honnef.co/go/tools/cmd/staticcheck@latest)"; \ | |
| fi | |
| @if ! command -v staticcheck >/dev/null 2>&1; then \ | |
| echo "Error: staticcheck is not installed. Please install it with:"; \ | |
| echo " go install honnef.co/go/tools/cmd/staticcheck@latest"; \ | |
| exit 1; \ | |
| fi | |
| staticcheck ./... |
| CGO_ENABLED=0 go install -ldflags "$(LDFLAGS)" . | ||
|
|
||
| clean: | ||
| rm -rf bin/ |
There was a problem hiding this comment.
clean currently only removes bin/, but this Makefile also creates other artifacts (coverage.out from coverage, and dist/ from goreleaser). Consider extending clean to remove those as well so it reliably resets the working tree.
| rm -rf bin/ | |
| rm -rf bin/ coverage.out dist/ |
| build: | ||
| @mkdir -p bin | ||
| CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY_NAME) . |
There was a problem hiding this comment.
make build always outputs bin/stackctl without the .exe suffix. On Windows, this produces a non-standard binary name (unlike go build default and unlike build-all, which correctly appends .exe) and can break expected execution/packaging on Windows. Consider adding a small OS check in the build target to append .exe when GOOS=windows (or when running on Windows).
| lint: | ||
| go vet ./... | ||
| @if command -v staticcheck >/dev/null 2>&1; then \ | ||
| staticcheck ./...; \ | ||
| else \ | ||
| echo "staticcheck not installed, skipping (go install honnef.co/go/tools/cmd/staticcheck@latest)"; \ | ||
| fi |
There was a problem hiding this comment.
The lint target suggests installing staticcheck@latest, while CI/release workflows pin staticcheck@2026.1. This can lead to local lint results diverging from CI. Consider centralizing the staticcheck version (e.g., a Makefile var) and referencing that in both the install hint and workflows (or add a make tools target that installs the pinned version).
…ck version - Detect GOOS=windows or OS=Windows_NT to append .exe in build target - Add STATICCHECK_VERSION variable (2026.1) and make tools target - Update CI and release workflows to use make tools instead of hardcoded version - Update lint hint to reference make tools Addresses PR review comments from #18
| go install honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION) | ||
|
|
||
| clean: | ||
| rm -rf bin/ |
There was a problem hiding this comment.
make clean only removes bin/, but this PR also introduces additional build artifacts (cli/dist from GoReleaser and coverage.out from make coverage). Consider extending clean to remove these as well so developers can reliably reset the workspace with one target.
| rm -rf bin/ | |
| rm -rf bin/ dist/ coverage.out |
| run: make test && make lint | ||
| - name: Run goreleaser | ||
| uses: goreleaser/goreleaser-action@v6 | ||
| with: |
There was a problem hiding this comment.
The release workflow pins the action major version (goreleaser/goreleaser-action@v6) but not the GoReleaser binary version itself, so a new GoReleaser release could change behavior or break releases unexpectedly. Consider setting the action input to pin a specific GoReleaser version (or at least a constrained major/minor range) for reproducible releases.
| with: | |
| with: | |
| version: v2.7.0 |
- Add dist/ and coverage.out to make clean target - Pin goreleaser to v2.7.0 in release workflow for reproducible builds Addresses PR review comments from #18
| RUN apk add --no-cache ca-certificates && \ | ||
| adduser -D -h /home/stackctl stackctl | ||
|
|
||
| COPY --from=builder /build/stackctl /usr/local/bin/stackctl | ||
|
|
||
| USER stackctl |
There was a problem hiding this comment.
The runtime image creates a non-root user but leaves the default login shell enabled. For a CLI container image, it’s safer to create the user with a nologin shell (or otherwise disable interactive login) to reduce the attack surface.
| - name: Run goreleaser | ||
| uses: goreleaser/goreleaser-action@v6 | ||
| with: | ||
| version: v2.7.0 | ||
| args: release --clean | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} |
There was a problem hiding this comment.
The PR description’s “Key Decisions” section appears corrupted (repeated/truncated text around goreleaser/Homebrew token and build targets). Please clean up the PR description so reviewers/users can understand the intent and required setup.
| @if command -v staticcheck >/dev/null 2>&1; then \ | ||
| staticcheck ./...; \ | ||
| else \ | ||
| echo "staticcheck not installed, skipping (run 'make tools' to install staticcheck@$(STATICCHECK_VERSION))"; \ |
There was a problem hiding this comment.
make lint currently succeeds even when staticcheck is missing (it prints a message and skips). This can hide lint failures locally and makes make lint behavior differ depending on whether tools are installed. Consider making lint fail with a clear error when staticcheck is not present, or have lint depend on tools so it always runs the full lint suite.
| echo "staticcheck not installed, skipping (run 'make tools' to install staticcheck@$(STATICCHECK_VERSION))"; \ | |
| echo "error: staticcheck not installed (run 'make tools' to install staticcheck@$(STATICCHECK_VERSION))" >&2; \ | |
| exit 1; \ |
Address PR review comment: lint target now exits with error instead of silently skipping when staticcheck is missing.
Summary
Adds complete build infrastructure and CI/CD pipeline for stackctl.
New Files
cli/Makefile.github/workflows/ci.yml.github/workflows/release.ymlv*tags: test → goreleaser with Homebrew tap publishing.goreleaser.ymlDockerfile.dockerignoreKey Decisions
Setup Required
Before the first release, add a
HOMEBREW_TAP_TOKENsecret to this repo (PAT withreposcope for pushing the Homebrew formula).Closes #8