DEV Community

Cover image for Verify That Your GitHub Actions Deployment Actually Landed on the Server
hello world_leo
hello world_leo

Posted on Edited on Originally published at leo-rio.com

Verify That Your GitHub Actions Deployment Actually Landed on the Server

I trusted GitHub Actions' green checkmark for months before I noticed it was lying to me. Not lying, exactly. It was reporting on whether curl returned 200. Whether the actual code on the server had changed was a separate question, and one nothing in the pipeline was answering.

Our deployment webhook, in outline:

GitHub Actions → HTTPS POST → deploy.php on server → runs deploy.sh
Enter fullscreen mode Exit fullscreen mode

Why webhooks, not SSH

Hypernode (our host) IP-whitelists SSH access. GitHub Actions runs on rotating Azure IPs that never survive the whitelist. Port 443 has no such restriction, so a webhook it is. This is a common pattern on managed hosting.

The workflow:

- name: Trigger deploy on server
  run: |
      response=$(curl -s -w "\n%{http_code}" \
        -X POST https://www.yoursite.nl/deploy.php \
        -H "X-Deploy-Token: ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \
        --max-time 300)

      code=$(echo "$response" | tail -n 1)
      if [ "$code" != "200" ]; then
          echo "❌ Deployment failed (HTTP $code)"
          exit 1
      fi
      echo "✅ Deployment successful"
Enter fullscreen mode Exit fullscreen mode

And deploy.php runs deploy.sh synchronously:

exec("bash {$script} 2>&1", $output, $exit);
echo implode("\n", $output) . "\n";
http_response_code($exit === 0 ? 200 : 500);
Enter fullscreen mode Exit fullscreen mode

exec() blocks until the script finishes. So GitHub gets an accurate exit code, right? Mostly. There's one case where the pipeline reports success while nothing on the server changed.

The silent lock skip

deploy.sh uses a lock file to prevent concurrent deploys:

LOCK_FILE="/tmp/mysite-deploy.lock"

if [ -f "$LOCK_FILE" ]; then
    log "ERROR: Deployment already in progress. Aborting."
    exit 1
fi

touch "$LOCK_FILE"
trap "rm -f $LOCK_FILE" EXIT
Enter fullscreen mode Exit fullscreen mode

Normal case: a second push arrives while the first deploy is still running. The second deploy.sh sees the lock, exits 1, deploy.php returns 500, GitHub marks the workflow failed. Correct behavior.

Broken case: the first deploy dies mid-way. Network blip on git fetch, OOM kill, curl times out on the composer install, whatever. The trap doesn't fire cleanly, the lock file stays. Every subsequent deploy silently exits 1 with the "already in progress" message. Depending on how your workflow parses the response, that failure may or may not surface loudly.

Red flag: a lock file left behind by a crashed process is the deployment equivalent of a hung phone line. Sounds like success until you check who's actually there. If your deploy pipeline uses a lock, it needs a way to distinguish "genuinely running" from "abandoned lock from last time."

Beyond the lock issue, the pipeline had a deeper hole. Even when a deploy succeeded, we had no proof of what commit actually landed. Verifying meant SSHing in and checking file timestamps, or grepping for a string we knew existed only in the latest commit. Not scalable.

Fix part 1: return 423 when locked

Detect the lock in deploy.php before invoking deploy.sh and return HTTP 423 Locked. It's the correct status code for "this resource is currently locked", and it lets the workflow tell the difference between a real deploy failure and a stale lock.

$lockFile = '/tmp/mysite-deploy.lock';
if (file_exists($lockFile)) {
    http_response_code(423);
    echo "LOCKED: Deployment already in progress. Try again shortly.\n";
    exit;
}

exec("bash {$script} 2>&1", $output, $exit);
echo implode("\n", $output) . "\n";
http_response_code($exit === 0 ? 200 : 500);
Enter fullscreen mode Exit fullscreen mode

Handle 423 explicitly in the workflow so the CI log names the actual failure:

if [ "$code" = "423" ]; then
    echo "⚠️  Deploy locked, previous deploy still running or lock is stale"
    exit 1
fi
if [ "$code" != "200" ]; then
    echo "❌ Deployment failed (HTTP $code)"
    exit 1
fi
Enter fullscreen mode Exit fullscreen mode

You could add lock-age detection so a lock file older than X minutes gets treated as stale and removed automatically. I chose to keep this manual, on the theory that a stale lock indicates something worth investigating before the next automated deploy tramples over whatever broke.

Fix part 2: write a version file

At the end of deploy.sh, after every step has completed, capture the deployed commit SHA and write it to a public file:

# Right after git reset --hard
DEPLOYED_COMMIT=$(git rev-parse HEAD)
log "Code updated (commit: $DEPLOYED_COMMIT)"

# ... composer install, yarn build, artisan migrate, etc.

# At the very end, after every other step succeeded
echo "$DEPLOYED_COMMIT $(date -u +%Y-%m-%dT%H:%M:%SZ)" > public/version.txt
log "Version file written: $DEPLOYED_COMMIT"
Enter fullscreen mode Exit fullscreen mode

Order matters. Write this after git clean (which would nuke a version.txt that isn't gitignored) and after every other build step (so a partial deploy doesn't advertise a SHA that isn't fully live). Add public/version.txt to .gitignore so it never gets committed.

Fix part 3: verify from the CI job

After the webhook returns, poll version.txt from GitHub Actions and confirm the SHA matches what we just pushed.

- name: Verify deployment landed
  run: |
      EXPECTED="${{ github.sha }}"
      echo "Expected SHA: $EXPECTED"

      for i in $(seq 1 20); do
          sleep 15
          DEPLOYED=$(curl -sf "https://www.yoursite.nl/version.txt" 2>/dev/null | awk '{print $1}')
          echo "Attempt $i/20: server=${DEPLOYED:-none}"
          if [ "$DEPLOYED" = "$EXPECTED" ]; then
              echo "✅ Deploy confirmed on server: $DEPLOYED"
              exit 0
          fi
      done

      echo "❌ Deploy verification FAILED after 5 minutes"
      echo "Expected: $EXPECTED"
      echo "Got:      ${DEPLOYED:-no response from server}"
      exit 1
Enter fullscreen mode Exit fullscreen mode

Polls every 15 seconds for up to 5 minutes. Since deploy.php blocks synchronously on exec(), by the time this step runs the deploy has already finished and the first poll almost always matches. The retry loop only matters if there's a CDN cache between GitHub Actions and the origin.

What it looks like now

The GitHub Actions log after a successful deploy:

Run EXPECTED="6f250fe1a2b9bb11ea826325a8a486b25279dfb1"
Waiting for deploy to complete on server...
Expected SHA: 6f250fe1a2b9bb11ea826325a8a486b25279dfb1
Attempt 1/20: server=6f250fe1a2b9bb11ea826325a8a486b25279dfb1
✅ Deploy confirmed on server: 6f250fe1a2b9bb11ea826325a8a486b25279dfb1
Enter fullscreen mode Exit fullscreen mode

Confirmed on the first attempt. And the same file is readable from anywhere without credentials:

$ curl -s https://www.yoursite.nl/version.txt
6f250fe1a2b9bb11ea826325a8a486b25279dfb1 2026-05-28T03:41:30Z
Enter fullscreen mode Exit fullscreen mode

Lesson learned: the deploy pipeline should prove that it succeeded, not assume that a 200 means it did. curl returning 200 says the webhook responded. It doesn't say the code changed. Two lines of version.txt plus a poll loop is the difference between hoping and knowing.

Why not just git log on the server

You could SSH in and run git log -1. That requires SSH access (blocked in our case), a separate monitoring job, or a human at a keyboard. The version.txt approach works over plain HTTPS from anywhere, from any browser, with no credentials. Same information, easier to consume, and the CI checks it automatically as part of the deploy job.

You could SSH in and run git log -1 — but that requires SSH access from CI (blocked for us), a separate monitoring job, or manual checks. The version.txt approach works over plain HTTPS with no
credentials, from anywhere, including your browser.

It also separates concerns: GitHub Actions verifies the outcome, not the process. Even if the internals of deploy.sh change, the verification contract stays the same "does the server report the right SHA?"


Summary

┌──────────────────────────────────────────────────┬───────────────────────────────────────┐
│                     Problem                      │                  Fix                  │
├──────────────────────────────────────────────────┼───────────────────────────────────────┤
│ No signal when deploy is locked                  │ deploy.php returns HTTP 423           │
├──────────────────────────────────────────────────┼───────────────────────────────────────┤
│ No proof of what commit landed                   │ deploy.sh writes public/version.txt   │
├──────────────────────────────────────────────────┼───────────────────────────────────────┤
│ GitHub shows success without server confirmation │ GitHub Actions polls and verifies SHA │
└──────────────────────────────────────────────────┴───────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Three small changes. Zero new dependencies. Works with any stack that can serve a static file over HTTP.

Top comments (0)