What Happens When You `git push` to CI
- lesson
- git-hooks
- webhooks
- ci-runners
- build-pipelines
- artifacts
- deploy-triggers
---# What Happens When You
git pushto 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:
- Identifies which commits the remote doesn't have
- Packs those commits (and their trees and blobs) into a packfile
- Sends the packfile over SSH or HTTPS to the remote
- 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-latestenvironment 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:
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
latestorv1.2.3are 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: testmeans 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?
- Running tests on every push to every branch
- Building Docker images only on main
- Deploying to staging automatically, production manually
- Running security scans nightly instead of on every push
- 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¶
Takeaways¶
-
The webhook is the bridge. Git push → webhook POST → CI picks up. Without the webhook, pushing code does nothing.
-
Tag with commit SHA, not
latest. Every image traceable to exactly one commit.latestis a lie — it changes every push. -
Ephemeral runners are a feature. No contamination between builds. But cache dependencies to avoid re-downloading every time.
-
Fast tests first. Tests in seconds, builds in minutes, deploys in minutes. Fail early, save time.
-
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.
Related Lessons¶
- 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