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
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"
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);
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
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);
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
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"
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
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
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
Lesson learned: the deploy pipeline should prove that it succeeded, not assume that a 200 means it did.
curlreturning 200 says the webhook responded. It doesn't say the code changed. Two lines ofversion.txtplus 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 │
└──────────────────────────────────────────────────┴───────────────────────────────────────┘
Three small changes. Zero new dependencies. Works with any stack that can serve a static file over HTTP.
Top comments (0)