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:
- 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.
- Run scripts.
- Set up automatic git commit pushes (requires a related PAT permission environment variable).
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:
```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
{ "[go]": { "editor.formatOnSave": true, "editor.defaultFormatter": "golang.go" },}