Improving Go code quality through CI static validation

Introduction

I recently noticed some friction in the commit workflow while developing Go projects — for example, using tabs for indentation might make widths look strange on GitLab, or small alignment issues silently drain focus. So I moved some native Go static-check tools into CI to ensure a consistent developer experience.

The Go ecosystem is great

When I used to add static analysis to frontend projects, I had to deal with TypeScript runtimes, ESLint, Prettier… plus various plugins to stop them from conflicting. In Go, there are already official static analysis tools to help developers tidy up code appearance and correctness:

Even the development setup is simple — just install the VSCode Go🔗 extension and you’re good to go. Today I only need to migrate the same process to CI to ensure a single source of truth where code gets properly validated.

Putting static analysis on GitLab CI

My project uses GitLab, and the process is basically:

  1. Use the golang alpine image to greatly reduce container size — note apt and bash are not avaliable so you need to install or find alternatives.
  2. Run scripts.
  3. Set up automatic git commit pushes (requires a related PAT permission environment variable).
.gitlab-ci.yml
stages:
- check
lint-govet:
image: golang:1.26-alpine
stage: check
script:
- go vet ./...
lint-gofmt:
image: golang:1.26-alpine
stage: check
before_script:
- apk add --no-cache git bash
- git config user.name "GitLab CI"
- git config user.email "ci@gitlab.com"
script:
- chmod +x scripts/gofmt.sh
- |
./scripts/gofmt.sh format || FORMAT_EXIT_CODE=$?
[ "${FORMAT_EXIT_CODE:-0}" -eq 2 ] && { echo "No files need formatting"; exit 0; }
if [ -n "$(git status --porcelain)" ]; then
git add -A
git commit -m "refactor: Auto gofmt [skip ci]"
git push "https://project_access_token:${GOFMT_DOC_AUTO_GEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:main
else
echo "No formatting changes detected."
fi
rules:
- if: $CI_COMMIT_BRANCH && $CI_COMMIT_MESSAGE !~ /\[skip ci\]/

Because my team didn’t have a gofmt habit before, automatically changing everything at once could cause too many merge conflicts, so I only format files that were changed. Here is the gofmt.sh bash script used:

gofmt.sh
```bash
#!/bin/bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
show_help() {
echo -e "${BLUE}Go Code Formatter${NC}"
echo ""
echo "Usage: $0 [command]"
echo ""
echo "Available commands:"
echo " format Format Go files modified in PR (default)"
echo " check Check files without modifying them"
echo " help Show this help message"
echo ""
echo "Examples:"
echo " $0 format # Format files modified in PR"
echo " $0 check # Check formatting only"
}
check_dependencies() {
if ! command -v go &> /dev/null; then
echo -e "${RED}Error: Go compiler not found${NC}"
exit 1
fi
}
get_target_branch() {
if [ -n "$CI_MERGE_REQUEST_DIFF_BASE_SHA" ]; then
echo "$CI_MERGE_REQUEST_DIFF_BASE_SHA"
elif [ -n "$CI_COMMIT_SHA" ]; then
echo "HEAD"
else
echo "origin/master"
fi
}
get_modified_files() {
cd "$PROJECT_ROOT"
local target_branch="${1:-origin/master}"
if [ -n "$CI_MERGE_REQUEST_DIFF_BASE_SHA" ]; then
git diff --name-only "$CI_MERGE_REQUEST_DIFF_BASE_SHA" HEAD -- '*.go' 2>/dev/null || echo ""
elif git rev-parse "$target_branch" &>/dev/null; then
git diff --name-only "$target_branch" HEAD -- '*.go' 2>/dev/null || echo ""
else
git diff --name-only --cached -- '*.go' 2>/dev/null
git diff --name-only -- '*.go' 2>/dev/null
fi
}
format_go_files() {
local mode="${1:-write}"
echo -e "${BLUE}Fetching modified Go files...${NC}"
cd "$PROJECT_ROOT"
local modified_files
modified_files=$(get_modified_files)
if [ -z "$modified_files" ]; then
echo -e "${YELLOW}No modified Go files found${NC}"
return 0
fi
echo -e "${BLUE}Modified files found:${NC}"
echo "$modified_files"
echo ""
local files_to_format=""
while IFS= read -r file; do
[ -z "$file" ] && continue
if [ -f "$file" ]; then
files_to_format="$files_to_format $file"
fi
done <<< "$modified_files"
if [ -z "$files_to_format" ]; then
echo -e "${YELLOW}No files to format${NC}"
return 0
fi
if [ "$mode" = "check" ]; then
echo -e "${BLUE}Checking Go file formatting (check only)...${NC}"
local diff_output
diff_output=$(gofmt -d $files_to_format 2>&1)
if [ -n "$diff_output" ]; then
echo -e "${YELLOW}The following files do not conform to gofmt standards:${NC}"
echo "$diff_output"
echo ""
echo -e "${RED}Format check failed${NC}"
return 1
else
echo -e "${GREEN}✓ All files passed format check${NC}"
return 0
fi
else
echo -e "${BLUE}Formatting Go files...${NC}"
gofmt -w $files_to_format
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ Go files formatted successfully!${NC}"
local changes
changes=$(git status --porcelain $files_to_format 2>/dev/null)
if [ -n "$changes" ]; then
echo -e "${YELLOW}The following files were formatted:${NC}"
echo "$changes"
return 0
else
echo -e "${YELLOW}No files needed formatting (already conformant)${NC}"
return 2
fi
else
echo -e "${RED}✗ Go file formatting failed${NC}"
return 1
fi
fi
}
main() {
check_dependencies
case "${1:-format}" in
format|f)
format_go_files "write"
;;
check|c)
format_go_files "check"
;;
help|h|--help|-h)
show_help
;;
*)
echo -e "${RED}Unknown command: $1${NC}"
echo ""
show_help
exit 1
;;
esac
}
main "$@"

Auto-format on save in VSCode

.vscode/setting.json
{
"[go]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "golang.go"
},
}