Skip to main content

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:

  1. 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
  1. Add to onboarding docs
  2. 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