The DevOps Odyssey, Part 6 — Closing the Loop with GitHub Auto-Tagging
originally posted at LinkedIn at November 13, 2025
In the last chapter, I left a promise — to make the system truly GitOps-native. To bridge the small but important gap between building images and updating manifests.
That loop is now closed.
Every time a Docker image for Job Winner or the photo app is built and pushed, GitHub Actions updates the Argo CD repository automatically. No manual tag edits, no pull requests waiting in the dark. The commit that produces the container now also defines its deployment.
The infrastructure finally breathes on its own.

From CI to Continuous Reconciliation
The workflow starts in the application repository — one for Jobwinner’s backend, one for its frontend UI. Each push triggers a GitHub Action that builds and publishes a multi-architecture image to ghcr.io.
# .github/workflows/deploy-jobwinner-backend.yml
on:
push:
paths:
- 'job-winner/**'
- '.github/workflows/deploy-jobwinner-backend.yml'
branches:
- '**'
jobs:
docker-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
uses: ./.github/actions/docker-build-or-tag
with:
token: ${{ secrets.GH_TOKEN }}
image-name: job-winner
dockerfile-dir: job-winner
update-deploy-repo-tag:
needs: docker-publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Update Argo CD deployment repo
uses: ./.github/actions/update-deployment-repo
with:
app_name: job-winner
deployment_type: backend
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
A similar workflow in deploy-jobwinner-frontend.yml does the same for the UI build. Both use the same two composite actions that form the backbone of this automation.
The Two Actions That Make It Work
The first composite action — docker-build-or-tag — builds and pushes the image, tagging both latest and the abbreviated commit SHA.
# .github/actions/docker-build-or-tag/action.yml
runs:
using: composite
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: januschung
password: ${{ inputs.token }}
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
push: true
platforms: linux/amd64,linux/arm64
context: ${{ inputs.dockerfile-dir }}
tags: |
ghcr.io/januschung/${{ inputs.image-name }}:${{ env.COMMIT_SHA }}
ghcr.io/januschung/${{ inputs.image-name }}:latest
Once the image is live in GHCR, the second composite action — update-deployment-repo — fires a repository_dispatch to the Argo CD repo, carrying a payload with the new tag, app name, and deployment type.
# .github/actions/update-deployment-repo/action.yml
runs:
using: composite
steps:
- uses: actions/create-github-app-token@v2
with:
app-id: ${{ inputs.app_id }}
private-key: ${{ inputs.private_key }}
owner: januschung
repository: argocd
- name: Trigger Argo CD repo dispatch
run: |
curl -sS -X POST \
-H "Authorization: token ${{ steps.app-token.outputs.token }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/januschung/argocd/dispatches \
-d '{"event_type":"update-tag","client_payload":{"tag":"${{ env.COMMIT_SHA }}","app_name":"${{ inputs.app_name }}","deployment_type":"${{ inputs.deployment_type }}"}}'
Short-Lived Trust with GitHub App Tokens
The trigger uses a GitHub App instead of a personal access token — and that choice matters. Each workflow generates a short-lived installation token, valid for about an hour and scoped only to the Argo CD repository. There are no static credentials to rotate, no personal tokens hidden in secrets. It’s ephemeral trust: the token exists just long enough to update a manifest, then disappears.
It’s a small design detail, but it’s what turns automation into governance — a system that not only acts automatically, but acts responsibly.
Updating the Argo CD Repository
In the Argo CD repository, an update-tag.yaml workflow listens for the repository_dispatch event. It checks out the repo, modifies the Helm chart values.yaml, creates a pull request, and auto-merges it.
# .github/workflows/update-tag.yaml
on:
repository_dispatch:
types: [update-tag]
jobs:
update-tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install yq
run: |
sudo curl -sL https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -o /usr/local/bin/yq
sudo chmod +x /usr/local/bin/yq
- name: Update image tag
run: |
cd lab/${{ github.event.client_payload.app_name }}/chart
yq -i ".${{ github.event.client_payload.deployment_type }}.image.tag = \"${{ github.event.client_payload.tag }}\"" values.yaml
- name: Create PR and enable auto-merge
uses: peter-evans/create-pull-request@v6
with:
commit-message: "Auto update image tag to ${{ github.event.client_payload.tag }}"
branch: auto/update-${{ github.event.client_payload.app_name }}
delete-branch: true
By the time the PR merges, Argo CD detects the manifest change and redeploys the updated image automatically. The workflow becomes a heartbeat — a small, invisible cycle that keeps the system alive and current.
Continuous Motion, Minimal Intervention
This is not about adding complexity; it’s about removing the last manual gesture. No human now decides when “latest” becomes “running.” The repository does.
The pattern scales naturally — every app can reuse the same pair of actions, regardless of language or build context. It’s self-similar, declarative, and composable — qualities that make GitOps more than just a pipeline.
The Jobwinner backend, the frontend UI, and even the photo app now move in rhythm. Each push ripples across the system, quietly, predictably.
Reflection
Each phase of this Odyssey has refined the boundaries between tools and intent. This one erases another — the point where CI ends and deployment begins.
From here, the next chapter will look outward again — installing Prometheus and Grafana, bringing visibility and voice to this living system. Metrics, logs, and dashboards that let the cluster tell its own story.
Somewhere between a commit and a container, between Git and light, the Odyssey continues.