Skip to content

What Happens When You `git push` to CI

  • lesson
  • git-hooks
  • webhooks
  • ci-runners
  • build-pipelines
  • artifacts
  • deploy-triggers ---# What Happens When You git push to CI

Topics: Git hooks, webhooks, CI runners, build pipelines, artifacts, deploy triggers Level: L1–L2 (Foundations → Operations) Time: 60–75 minutes Prerequisites: Basic Git usage


The Mission

You type git push origin main. Thirty seconds later, your code is being tested. Two minutes later, a Docker image is built. Three minutes later, it's deployed to staging. You didn't click a single button.

What just happened? At least 8 systems coordinated: Git, a webhook, a CI server, a runner, a container registry, a deployment tool, and your cluster. This lesson follows the push from your terminal through every hop.


Step 1: Git Packs and Pushes Objects

When you git push, Git:

  1. Identifies which commits the remote doesn't have
  2. Packs those commits (and their trees and blobs) into a packfile
  3. Sends the packfile over SSH or HTTPS to the remote
  4. The remote unpacks and updates the branch ref
# See what git push actually transfers
GIT_TRACE=1 git push origin main 2>&1 | grep -E "pack|send|receive"
# → Enumerating objects: 5, done.
# → Counting objects: 100% (5/5), done.
# → Writing objects: 100% (3/3), 1.23 KiB | 1.23 MiB/s, done.

Under the Hood: Git objects are content-addressed — identified by SHA-1 hash of their contents. If the remote already has a blob (identical file contents), it's not resent. This is why pushing one changed file in a huge repo is fast — only the diff is new.

Trivia: Git pack files use delta compression — objects are stored as diffs against similar objects. A 100MB repo with 10,000 commits might have a .pack file of 5MB because most commits change only a few lines. This was one of Linus's key insights: store the content, not the files, and let compression handle the rest.


Step 2: Server-Side Hooks Fire

When the remote receives the push, it runs server-side hooks:

pre-receive    → Can reject the push (branch protection, commit signing, linting)
update         → Runs per-branch (can accept main but reject feature/*)
post-receive   → Runs after push is accepted (triggers CI, sends notifications)

GitHub's branch protection rules, required status checks, and signed commit requirements all run in the pre-receive phase. If any check fails, the push is rejected — your commits never land.

# GitHub-specific: branch protection runs here
# If you don't have required reviews, the push is rejected:
# → remote: error: GH006: Protected branch update failed
# →   Required status check "tests" is expected.

Gotcha: Server-side hooks run on the Git server, not your machine. You can't see or modify them for hosted repos (GitHub, GitLab). Client-side hooks (pre-commit, pre-push) run on your machine but can be bypassed with --no-verify.


Step 3: Webhook Fires — Git Talks to CI

After post-receive, the hosting platform sends a webhook — an HTTP POST to your CI server with details about the push:

{
  "ref": "refs/heads/main",
  "after": "abc123def456",
  "repository": {
    "full_name": "myorg/myapp",
    "clone_url": "https://github.com/myorg/myapp.git"
  },
  "pusher": {
    "name": "alice",
    "email": "alice@example.com"
  }
}

The CI server (GitHub Actions, GitLab CI, Jenkins) receives this webhook and decides what to do based on the branch and event type.

# GitHub Actions: triggered by the webhook
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

Step 4: CI Runner Picks Up the Job

The CI server creates a job and assigns it to a runner — a machine (or container) that executes your pipeline steps.

# .github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest    # GitHub-hosted runner
    steps:
      - uses: actions/checkout@v4       # Clone the repo
      - uses: actions/setup-python@v5   # Install Python
        with:
          python-version: '3.11'
      - run: pip install -r requirements.txt
      - run: pytest --cov=app/

  build:
    needs: test                         # Only runs if test passes
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/myorg/myapp:${{ github.sha }}

  deploy:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'  # Only deploy from main
    steps:
      - run: kubectl set image deployment/myapp myapp=ghcr.io/myorg/myapp:${{ github.sha }}

The pipeline is a DAG (directed acyclic graph): test → build → deploy. Each step only runs if the previous succeeded.

Trivia: GitHub Actions launched in 2019, 11 years after GitHub was founded. Before that, most GitHub users used external CI (Travis CI, CircleCI, Jenkins). Travis CI was the first CI service to offer free builds for open-source projects (2011), which revolutionized how open-source projects tested code.


Step 5: Tests Run

The runner clones your repo (at the exact commit SHA from the push), installs dependencies, and runs your test suite:

# What the runner actually does:
git clone --depth 1 --branch main https://github.com/myorg/myapp.git
cd myapp
git checkout abc123def456    # Exact commit from the push
pip install -r requirements.txt
pytest --cov=app/ --junitxml=results.xml

If tests fail, the pipeline stops. The commit gets a red X on GitHub. If tests pass, the build step starts.

Gotcha: The runs-on: ubuntu-latest environment is ephemeral — it's created fresh for every job and destroyed after. Nothing persists between runs. This is both a security feature (no contamination between builds) and a performance cost (dependencies must be re-downloaded every time unless you use caching).


Step 6: Docker Image Built and Pushed

The build step creates a Docker image tagged with the commit SHA and pushes it to a container registry:

Build: Dockerfile → layers → image
Tag:   ghcr.io/myorg/myapp:abc123def456
Push:  Upload to GitHub Container Registry (or ECR, Docker Hub, etc.)

Tagging with the commit SHA means every image is traceable to exactly one commit. No ambiguity, no "which version is deployed?"

# On any system, find exactly which code is running:
kubectl get deployment myapp -o jsonpath='{.spec.template.spec.containers[0].image}'
# → ghcr.io/myorg/myapp:abc123def456

# That SHA maps to one commit:
git log --oneline abc123def456 -1
# → abc123d Fix authentication timeout

Step 7: Deploy to Staging/Production

The deploy step updates the Kubernetes Deployment with the new image:

kubectl set image deployment/myapp myapp=ghcr.io/myorg/myapp:abc123def456

Kubernetes performs a rolling update: new pods start, pass readiness probes, old pods terminate. Zero downtime.

For production, many teams add a manual approval gate:

deploy-prod:
  needs: deploy-staging
  environment: production    # Requires manual approval in GitHub
  steps:
    - run: kubectl --context=production set image deployment/myapp ...

The Complete Flow

[1] git push origin main
    → Git packs objects, sends via SSH/HTTPS

[2] Remote receives push
    → pre-receive hooks (branch protection)
    → post-receive hooks (webhook triggers)

[3] Webhook POST to CI server
    → "main branch updated, commit abc123"

[4] CI creates job, assigns to runner
    → Runner clones repo at exact commit SHA

[5] Test stage
    → Install deps → run tests → pass/fail

[6] Build stage (if tests passed)
    → Docker build → tag with commit SHA → push to registry

[7] Deploy stage (if build passed)
    → kubectl set image → rolling update → new pods start

Total time: 3-10 minutes from push to deployed

Flashcard Check

Q1: What triggers CI after git push?

A webhook — an HTTP POST from the Git hosting platform to the CI server. The webhook contains the branch, commit SHA, and repository details.

Q2: Why tag Docker images with the commit SHA?

Traceability. Given any running image, you can find the exact source code commit. Tags like latest or v1.2.3 are mutable and can point to different images over time.

Q3: runs-on: ubuntu-latest — is state preserved between runs?

No. The environment is ephemeral — created fresh and destroyed after each job. Dependencies must be cached explicitly or re-downloaded.

Q4: Why run tests before build?

Fast failure. Tests take seconds; Docker builds take minutes. Failing early saves time and compute resources.

Q5: What does the needs: keyword do in GitHub Actions?

Creates a dependency. build: needs: test means build only runs if test succeeds. The pipeline is a DAG.


Exercises

Exercise 1: Trace your own CI (hands-on)

Look at a recent CI run in your project: 1. How long did each stage take? 2. What triggered it? (push, PR, schedule?) 3. What would happen if you pushed a commit that fails tests?

Exercise 2: The decision (think)

For each CI feature, when would you use it?

  1. Running tests on every push to every branch
  2. Building Docker images only on main
  3. Deploying to staging automatically, production manually
  4. Running security scans nightly instead of on every push
  5. Using self-hosted runners instead of GitHub-hosted
Answers 1. **Every push, every branch** — catch bugs before PR review. Fast tests (<5 min) make this practical. Slow tests: run on PR only. 2. **Build on main only** — feature branches produce throwaway images. Main produces deployable artifacts. Exception: build on PR too if you need integration tests. 3. **Auto-staging, manual production** — staging validates automatically. Production gets a human approval gate. This catches "it works in staging" bugs before users see them. 4. **Nightly security scans** — CVE scanning is slow and doesn't change between pushes. Run nightly + on merge to main. 5. **Self-hosted** — when you need GPU, ARM, or access to internal resources. GitHub-hosted for everything else (simpler, no maintenance).

Cheat Sheet

GitHub Actions Key Concepts

Concept What it is
Workflow YAML file defining the pipeline (.github/workflows/*.yml)
Job A set of steps that run on one runner
Step One command or action within a job
Runner Machine that executes the job
Action Reusable step (e.g., actions/checkout@v4)
Secret Encrypted variable (${{ secrets.DEPLOY_KEY }})
Artifact File produced by a job (test results, binaries)

Pipeline DAG Pattern

test → build → deploy-staging → (manual gate) → deploy-production

Takeaways

  1. The webhook is the bridge. Git push → webhook POST → CI picks up. Without the webhook, pushing code does nothing.

  2. Tag with commit SHA, not latest. Every image traceable to exactly one commit. latest is a lie — it changes every push.

  3. Ephemeral runners are a feature. No contamination between builds. But cache dependencies to avoid re-downloading every time.

  4. Fast tests first. Tests in seconds, builds in minutes, deploys in minutes. Fail early, save time.

  5. Manual gate for production. Auto-deploy to staging. Human approval for production. The gap between "it passed CI" and "it's safe for users" is real.


  • What Happens When You docker build — what the build step actually does
  • What Happens When You kubectl apply — what the deploy step triggers
  • The Rollback That Wasn't — when the deploy needs to be undone
  • The Git Disaster Recovery Guide — when the push itself goes wrong