You have CI building images and ArgoCD syncing manifests. The missing piece: how does CI tell ArgoCD "there's a new image"? Answer: CI commits the new image tag to the GitOps repo. This lesson wires it together end-to-end.
The Complete End-to-End Flow
🏋️ The CI Workflow That Updates GitOps
In your app repo (cicd-mastery), create this workflow:
# .github/workflows/ci-gitops.yml
name: CI + GitOps Update
on:
push:
branches: [main]
paths-ignore: ['**.md']
permissions:
id-token: write
contents: read
env:
IMAGE: cicd-mastery-api
GITOPS_REPO: YOUR_USERNAME/cicd-mastery-gitops
jobs:
# ─── CI: Test ───
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci && npm test
# ─── Build & Push Image ───
build:
needs: test
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.value }}
steps:
- uses: actions/checkout@v4
- id: tag
run: echo "value=sha-$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- run: az acr login --name ${{ vars.ACR_NAME }}
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ vars.ACR_LOGIN_SERVER }}/${{ env.IMAGE }}:${{ steps.tag.outputs.value }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ─── Update GitOps Repo (THE BRIDGE) ───
update-gitops:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout GitOps repo
uses: actions/checkout@v4
with:
repository: ${{ env.GITOPS_REPO }}
token: ${{ secrets.GITOPS_PAT }}
- name: Update image tag in staging
run: |
cd environments/staging
# Update kustomization.yaml with new tag
sed -i "s/newTag: .*/newTag: ${{ needs.build.outputs.tag }}/" kustomization.yaml
cat kustomization.yaml # Show the change
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git diff --staged --quiet && echo "No changes" && exit 0
git commit -m "deploy(staging): ${{ needs.build.outputs.tag }}
Source: ${{ github.repository }}@${{ github.sha }}"
git push
Setting Up the GitOps PAT
The CI pipeline needs write access to the GitOps repo. Create a fine-grained PAT:
- GitHub → Settings → Developer Settings → Personal Access Tokens → Fine-grained
- Repository access: Only select repositories →
cicd-mastery-gitops - Permissions: Contents → Read and write
- Generate → copy token
- Add as secret
GITOPS_PATin your app repo
The PAT only has write access to the GitOps repo — NOT the cluster. This is the security advantage: even if this token leaks, the attacker can only change Git (which is reviewed and auditable), not directly access the cluster.
What Happens After Push
Verifying the Flow
# After pushing to app repo, watch:
# 1. Check CI ran (GitHub Actions tab)
# 2. Check GitOps repo has new commit
cd cicd-mastery-gitops && git pull
cat environments/staging/kustomization.yaml
# Should show: newTag: sha-XXXXXXX (the new tag!)
# 3. Check ArgoCD detected it
argocd app get cicd-mastery-staging
# Status: Synced, Health: Healthy
# 4. Check pods
kubectl get pods -n staging -o jsonpath='{.items[0].spec.containers[0].image}'
# Should show: acr.../cicd-mastery-api:sha-XXXXXXX ✓
🧠 Recall Check
- What exactly does CI commit to the GitOps repo?
- Why use a fine-grained PAT scoped to only the GitOps repo?
- After CI pushes to the GitOps repo, what triggers the actual K8s deployment?
- How would you rollback a bad deployment in this model?
Reveal answers
- Only the image tag (e.g., changing
newTag: sha-oldtonewTag: sha-newin the kustomization.yaml). This is the minimal change needed to trigger a new deployment. - Least privilege. Even if compromised, it can only write to Git (reviewable), not access the cluster directly. And it's scoped to just one repository.
- ArgoCD's polling loop (every 3 min) or a webhook. ArgoCD sees the Git commit, detects "OutOfSync", and auto-syncs (if automated policy is set).
git revert <commit>in the GitOps repo. This changes the tag back to the previous value. ArgoCD sees the revert, syncs, and deploys the old image. Rollback in seconds.
The full GitOps loop is now operational. Code → CI → Image → GitOps commit → ArgoCD → Cluster. And rollback is just
git revert. Next: scaling this to many services with App-of-Apps.