pre push validation
Pre-Push Validation
Overview
The best way to save CI minutes is to not run CI at all for broken code. Pre-push validation catches errors locally before they consume expensive CI resources.
Cost Impact: Every prevented pipeline = 10-30 minutes saved
Why Pre-Push Validation?
The Problem
Typical waste pattern:
1. Write code
2. Push to GitLab
3. Wait 2 minutes for pipeline to start
4. Wait 5 minutes for linting/type-check
5. Pipeline fails on typo
6. Fix locally
7. Push again
8. Repeat 3-5
Wasted: 10+ minutes of CI time + 15 minutes of developer time
The Solution
Validate before push:
1. Write code
2. Pre-commit hook catches typo (10 seconds)
3. Fix locally
4. Push to GitLab
5. Pipeline passes (5 minutes)
Saved: 10 minutes CI + 10 minutes developer time
Local GitLab CI Testing
Tool: gitlab-ci-local
Install:
npm install -g gitlab-ci-local
Features:
- Run GitLab CI pipelines locally
- Test changes before pushing
- Supports most GitLab CI features
- Fast feedback loop
Basic Usage
Run entire pipeline:
gitlab-ci-local
Run specific job:
gitlab-ci-local lint gitlab-ci-local test
Dry run (see what would run):
gitlab-ci-local --list
Configuration
Create .gitlab-ci-local-env:
# Environment variables for local testing CI_COMMIT_BRANCH=main CI_COMMIT_SHA=local NODE_ENV=test
Add to .gitignore:
echo ".gitlab-ci-local-env" >> .gitignore
Advanced Usage
Run with specific variables:
gitlab-ci-local --variable NODE_VERSION=20 test
Mount volumes:
gitlab-ci-local --mount ~/.npm:/root/.npm test
Use local images:
gitlab-ci-local --use-local-image node:20 test
Integration with npm scripts
package.json:
{ "scripts": { "ci:local": "gitlab-ci-local", "ci:lint": "gitlab-ci-local lint", "ci:test": "gitlab-ci-local test", "prepush": "npm run ci:lint" } }
Limitations
Not supported:
- GitLab-specific features (artifacts between stages in some cases)
- Include remote files (partially supported)
- Some rules conditions
- Shared runners (uses local Docker)
Workaround: Use for fast checks (lint, unit tests), rely on CI for integration/E2E.
Pre-Commit Hooks
Tool: Husky + lint-staged
Install:
npm install --save-dev husky lint-staged npx husky init
Configure package.json:
{ "lint-staged": { "*.{js,jsx,ts,tsx}": [ "eslint --fix", "prettier --write" ], "*.{json,md,yml,yaml}": [ "prettier --write" ] } }
Create pre-commit hook:
# .husky/pre-commit npx lint-staged
Make executable:
chmod +x .husky/pre-commit
What to Check
Fast checks only (<30 seconds):
- Linting
- Formatting
- Type checking (TypeScript)
- .gitlab-ci.yml validation
- Full test suite (too slow)
- Build process (too slow)
- Integration tests (too slow)
GitLab CI YAML Validation
Pre-commit hook to validate .gitlab-ci.yml:
Install validator:
npm install --save-dev @gitbeaker/node
Create script scripts/validate-gitlab-ci.js:
#!/usr/bin/env node const { Gitlab } = require('@gitbeaker/node'); const fs = require('fs'); async function validateGitLabCI() { const api = new Gitlab({ host: process.env.CI_SERVER_URL || 'https://gitlab.com', token: process.env.GITLAB_TOKEN, }); const ciConfig = fs.readFileSync('.gitlab-ci.yml', 'utf8'); try { const result = await api.Lint.lint(ciConfig); if (result.valid) { console.log(' .gitlab-ci.yml is valid'); process.exit(0); } else { console.error(' .gitlab-ci.yml is invalid:'); result.errors.forEach(error => console.error(` - ${error}`)); process.exit(1); } } catch (error) { console.error('Error validating .gitlab-ci.yml:', error.message); process.exit(1); } } validateGitLabCI();
Update .husky/pre-commit:
#!/bin/sh . "$(dirname "$0")/_/husky.sh" # Only validate if .gitlab-ci.yml changed if git diff --cached --name-only | grep -q "^\.gitlab-ci\.yml$"; then echo "Validating .gitlab-ci.yml..." node scripts/validate-gitlab-ci.js fi npx lint-staged
Alternative: Docker-based Validation
Use GitLab's Docker image:
# .husky/pre-commit if git diff --cached --name-only | grep -q "^\.gitlab-ci\.yml$"; then docker run --rm -v "$PWD":/builds/project \ gitlab/gitlab-runner:latest \ exec docker --docker-image=alpine:latest \ --builds-dir=/builds \ --cache-dir=/cache \ /builds/project/.gitlab-ci.yml fi
Pre-Commit Hook for Multiple Checks
Complete .husky/pre-commit:
#!/bin/sh . "$(dirname "$0")/_/husky.sh" echo "Running pre-commit checks..." # Validate .gitlab-ci.yml if git diff --cached --name-only | grep -q "^\.gitlab-ci\.yml$"; then echo " Validating .gitlab-ci.yml..." node scripts/validate-gitlab-ci.js || exit 1 fi # Lint and format staged files echo " Linting and formatting..." npx lint-staged || exit 1 # Type check (TypeScript) if [ -f "tsconfig.json" ]; then echo " Type checking..." npx tsc --noEmit || exit 1 fi # Run fast unit tests on changed files echo " Running tests on changed files..." npm run test:changed --silent || exit 1 echo " All pre-commit checks passed"
Pre-Push Hooks
When to Use
Pre-push vs pre-commit:
- Pre-commit: Fast checks (<10s)
- Pre-push: Medium checks (<60s)
Use pre-push for:
- Unit tests
- Local CI validation
- Security checks
- License compliance
Setup
Create .husky/pre-push:
#!/bin/sh . "$(dirname "$0")/_/husky.sh" echo "Running pre-push checks..." # Run unit tests echo " Running unit tests..." npm test || exit 1 # Run security audit echo " Security audit..." npm audit --audit-level=high || exit 1 # Validate pipeline locally (if installed) if command -v gitlab-ci-local &> /dev/null; then echo " Validating pipeline locally..." gitlab-ci-local lint || exit 1 fi echo " All pre-push checks passed"
Selective Pre-Push Checks
Only run on specific branches:
#!/bin/sh BRANCH=$(git rev-parse --abbrev-ref HEAD) if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "development" ]; then echo " Pushing to $BRANCH - running full checks..." npm test || exit 1 npm run test:integration || exit 1 else echo "Feature branch - running quick checks..." npm run lint || exit 1 fi
GitLab CI Lint Pre-Commit Hooks
Using Pre-Commit Framework
Install pre-commit:
pip install pre-commit
Create .pre-commit-config.yaml:
repos: # Validate GitLab CI configuration - repo: https://github.com/emmeowzing/gitlabci-lint-pre-commit-hook rev: v1.3.1 hooks: - id: gitlabci-lint args: ['--server', 'https://gitlab.com'] # Standard hooks - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-json - id: check-merge-conflict # Language-specific - repo: https://github.com/pre-commit/mirrors-eslint rev: v8.56.0 hooks: - id: eslint files: \.(js|jsx|ts|tsx)$ args: ['--fix']
Install hooks:
pre-commit install pre-commit install --hook-type pre-push
Run manually:
pre-commit run --all-files
Advantages
- Language-agnostic
- Large ecosystem of hooks
- Automatic updates with
pre-commit autoupdate - Runs in CI easily
Add to CI:
pre-commit: image: python:3.11 before_script: - pip install pre-commit script: - pre-commit run --all-files --show-diff-on-failure
Local Testing Environment
Docker Compose for Dependencies
Problem: CI fails because of missing services (database, Redis, etc.)
Solution: Local docker-compose for testing
docker-compose.test.yml:
version: '3.8' services: postgres: image: postgres:15-alpine environment: POSTGRES_DB: test_db POSTGRES_USER: test POSTGRES_PASSWORD: test ports: - "5432:5432" redis: image: redis:7-alpine ports: - "6379:6379" app: build: . depends_on: - postgres - redis environment: DATABASE_URL: postgres://test:test@postgres:5432/test_db REDIS_URL: redis://redis:6379 command: npm test
Run locally:
docker-compose -f docker-compose.test.yml up --abort-on-container-exit
Add to npm scripts:
{ "scripts": { "test:local": "docker-compose -f docker-compose.test.yml up --abort-on-container-exit", "test:local:clean": "docker-compose -f docker-compose.test.yml down -v" } }
IDE Integration
VSCode
Install extensions:
- ESLint
- Prettier
- GitLab Workflow
Settings.json:
{ "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, "gitlab.instanceUrl": "https://gitlab.com", "gitlab.pipelineGitRemoteName": "origin" }
Benefits:
- Catch errors while typing
- Auto-fix on save
- See pipeline status in IDE
- Validate .gitlab-ci.yml in IDE
WebStorm/IntelliJ
Enable:
- Settings Languages & Frameworks JavaScript Code Quality Tools ESLint
- Settings Tools Actions on Save Reformat code, Run eslint --fix
CI/CD Component for Local Validation
Create reusable validation component:
gitlab-components/.gitlab-ci.yml:
spec: inputs: skip-tests: default: false skip-lint: default: false --- validate: stage: .pre image: node:20-alpine rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" script: - echo "Running validation checks..." # Install dependencies - npm ci --prefer-offline # Lint - | if [ "$[[ inputs.skip-lint ]]" != "true" ]; then npm run lint fi # Type check - npm run type-check || echo "No type-check script" # Unit tests - | if [ "$[[ inputs.skip-tests ]]" != "true" ]; then npm test fi # Security audit - npm audit --audit-level=high || true - echo " All validation checks passed"
Use in projects:
include: - component: $CI_SERVER_HOST/blueflyio/gitlab-components/validate@v1.0.0
Measuring Pre-Push Validation Impact
Before Pre-Push Validation
Typical month:
Failed pipelines: 150
Average failure time: 5 minutes
Total wasted minutes: 750
Cost: $7.50
Developer time wasted: 25 hours
After Pre-Push Validation
Typical month:
Failed pipelines: 30 (80% reduction)
Average failure time: 5 minutes
Total wasted minutes: 150
Cost: $1.50
Developer time saved: 20 hours
Savings: $6/month per project, 20 hours developer time
Tracking Metrics
Add to CI:
report:failure-rate: stage: .post when: always script: - | FAILED=$(glab api /projects/$CI_PROJECT_ID/pipelines?status=failed | jq 'length') TOTAL=$(glab api /projects/$CI_PROJECT_ID/pipelines | jq 'length') RATE=$((100 * FAILED / TOTAL)) echo "Pipeline failure rate: $RATE%" if [ $RATE -gt 10 ]; then echo " High failure rate detected. Consider:" echo " - Pre-commit hooks" echo " - Local CI testing" echo " - Better test coverage" fi
Best Practices
Do's
Validate .gitlab-ci.yml before commit
Run lint and format on pre-commit
Run unit tests on pre-push
Use gitlab-ci-local for testing pipeline changes
Keep hooks fast (<30s pre-commit, <60s pre-push)
Allow bypass for emergencies (git commit --no-verify)
Document hook requirements in README
Don'ts
Don't run entire test suite in pre-commit (too slow) Don't run integration tests in pre-push (use CI) Don't make hooks mandatory without warning (document first) Don't rely solely on pre-commit (developers can bypass) Don't forget to update hooks (keep tooling current)
Troubleshooting
"Hooks too slow"
Solution: Optimize or move to pre-push
# Measure hook time time git commit -m "test" # If >10s, move to pre-push or optimize
"Hooks keep failing"
Solution: Check for false positives
# Debug hook sh -x .husky/pre-commit
"Can't push in emergency"
Solution: Bypass hooks (document when appropriate)
git push --no-verify
Add to docs:
## Emergency Push In critical situations, bypass hooks with: \`\`\`bash git push --no-verify \`\`\` **WARNING:** CI will still catch errors. Only use for: - Production incidents - Time-critical hotfixes - When local environment is broken
"Team not using hooks"
Solutions:
- Enforce in CI:
check-hooks: script: - | if [ ! -f ".husky/pre-commit" ]; then echo " Pre-commit hooks not installed!" echo "Run: npm install && npx husky install" exit 1 fi
- Add to onboarding docs
- Make installation automatic:
{ "scripts": { "postinstall": "husky install" } }
Complete Setup Example
package.json:
{ "scripts": { "postinstall": "husky install", "lint": "eslint . --ext .js,.jsx,.ts,.tsx", "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", "type-check": "tsc --noEmit", "test": "jest", "test:changed": "jest --onlyChanged --passWithNoTests", "validate:ci": "node scripts/validate-gitlab-ci.js", "ci:local": "gitlab-ci-local", "prepush": "npm run lint && npm test" }, "devDependencies": { "husky": "^8.0.0", "lint-staged": "^15.0.0", "@gitbeaker/node": "^40.0.0" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ "eslint --fix", "prettier --write" ], ".gitlab-ci.yml": [ "node scripts/validate-gitlab-ci.js" ] } }
.husky/pre-commit:
#!/bin/sh . "$(dirname "$0")/_/husky.sh" echo " Running pre-commit checks..." # Lint-staged (linting, formatting, CI validation) npx lint-staged # Type check if [ -f "tsconfig.json" ]; then echo " Type checking..." npm run type-check fi echo " Pre-commit checks passed"
.husky/pre-push:
#!/bin/sh . "$(dirname "$0")/_/husky.sh" echo " Running pre-push checks..." # Unit tests echo " Running unit tests..." npm test # Local CI validation (if installed) if command -v gitlab-ci-local &> /dev/null; then echo " Testing pipeline locally..." gitlab-ci-local lint test:unit fi echo " Pre-push checks passed"
Next Steps
- Monitoring - Track the impact of pre-push validation
- Checklist - Daily best practices
- Strategies - Combine with other cost optimizations