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

① Dev pushes to app repo (main) ② CI runs test → build → push ACR ③ CI updates GitOps repo commits new image tag ④ ArgoCD syncs new image deployed 📁 App Repo src/, Dockerfile, .github/workflows/ CI builds + pushes image here 📁 GitOps Repo environments/, base/, apps/ CI commits image tag update here ☸️ AKS Cluster ArgoCD watches GitOps repo Syncs desired state to cluster CI commits tag update pull The ONLY bridge between CI and the cluster is a Git commit. CI never touches K8s directly.

🏋️ 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:

  1. GitHub → Settings → Developer Settings → Personal Access Tokens → Fine-grained
  2. Repository access: Only select repositoriescicd-mastery-gitops
  3. Permissions: Contents → Read and write
  4. Generate → copy token
  5. Add as secret GITOPS_PAT in 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

0s: push code ~60s: image built ~65s: GitOps updated ~250s: ArgoCD detects (or instant with webhook) ~280s: pods updated Push to running in ~5 min (staging, fully automated)
End-to-end: code push to running pods in about 5 minutes. With webhooks, under 3 minutes.

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

  1. What exactly does CI commit to the GitOps repo?
  2. Why use a fine-grained PAT scoped to only the GitOps repo?
  3. After CI pushes to the GitOps repo, what triggers the actual K8s deployment?
  4. How would you rollback a bad deployment in this model?
Reveal answers
  1. Only the image tag (e.g., changing newTag: sha-old to newTag: sha-new in the kustomization.yaml). This is the minimal change needed to trigger a new deployment.
  2. 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.
  3. 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).
  4. 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.