---
title: "Safe deploys: dry-run by default, parity checks and verifying what actually shipped"
description: "A successful deploy command proves that a transfer happened, not what was transferred. We make deploys safe with three habits: every deploy script is a dry run unless given --apply, a daily parity check compares server files with git, and after publishing we compare the served source and response bodies with what we meant to ship."
date: 2026-10-11
lang: en
canonical: https://goingstellar.ro/methods/safe-deploys-dry-run-parity-checks/
author: Going Stellar
publisher: GOING STELLAR S.R.L.
topic: Engineering / Deployment
license: CC BY 4.0
alternate_ro: https://goingstellar.ro/ro/metode/deploy-sigur-dry-run-paritate/
---

# Safe deploys: dry-run by default, parity checks and verifying what actually shipped

*A successful deploy command proves that a transfer happened, not what was transferred. We make deploys safe with three habits: every deploy script is a dry run unless given --apply, a daily parity check compares server files with git, and after publishing we compare the served source and response bodies with what we meant to ship.*

> Engineering · Deployment · 2026-10-11 · https://goingstellar.ro/methods/safe-deploys-dry-run-parity-checks/

## Key points

- A successful deploy or push proves that a transfer happened, not what was transferred; compare the source the server serves with your local source after every deploy.
- Deploy scripts should run as a dry run by default and require an explicit --apply, especially when they delete files on the server.
- A deploy publishes whatever is on disk, so never deploy while another person or agent is writing to the same source tree.
- A 200 status code proves that a server answered, not what it answered; a single-page application returns 200 with its index page for every unknown path.
- Shipping code to a platform and the platform knowing that a new scheduled function exists are two different facts; re-register after every production deploy and fail loudly if registration is refused.

## What a safe deploy is

A safe deploy is one where you know, before and after, exactly what changed on the server. In practice that means three habits: every deploy script runs as a **dry run by default** and needs an explicit `--apply` to write; a **parity check** compares the files on the server with the committed source and complains about any difference; and after publishing you **verify what is actually being served**, by reading content, not status codes.

A successful deploy command proves that a transfer happened. It does not prove what was transferred, whether the platform noticed it, or whether anything else on the server changed as a side effect. Every rule below came from an incident where the deploy reported success.

## Step 1: dry run by default

A dry run is a mode in which the deploy script lists what it would copy and delete without touching the server. Our website deploy script builds the site from source first, then calls `rsync` with `--checksum --delete --dry-run` unless `--apply` is passed. Building first matters: the published files must come from the sources, not from whatever was left on disk by the last run. The `--delete` flag removes server files that no longer exist locally, which is exactly why the dry run is the default: read the list of deletions before you press the button.

Our server-side deploy script for internal tools follows the same rule: dry run by default, a backup copy of every file it replaces, and `git pull --ff-only` so that the server never merges or rewrites history on its own.

## Step 2: deploy only from a known, quiet tree

**A deploy publishes whatever is on disk, including other people's unfinished work.** One of our deploy scripts synchronised the whole working tree with `rsync --delete`. We deployed **six** times in one session and, without editing a single file of theirs, published the in-progress work of another AI agent working in parallel on the same project. Its database migration had already run in production. The build passed and the health check answered, so nothing signalled the problem, and the project was not under git, so there was no `git status` to warn us either.

**Never deploy while a parallel workflow is writing to the source files.** One morning, two versions of our Google Workspace add-ons were published with the wrong content: one carried another add-on's panel file, the other a debugging probe visible in its panel. Parallel agents had been swapping files in and out of the same folder, and for a few seconds each file held something else. `clasp push` reported success, because the transfer worked.

Before the first deploy into a shared project we now list the files modified today and check who wrote them, tell the other session before deploying rather than after, and check for collisions in migration numbering. The deploy script refuses to start if the app has uncommitted changes.

## Step 3: parity between git and the server

A parity check compares each deployed file on the server with the same file in the main branch and reports any file that differs. Divergence between what git says and what runs is how most of our drift problems started: files copied by hand with `scp` that never went back into git, or fixes made on the server and lost at the next deploy.

Our parity tool discovers the deployed files from the git tree, runs on a daily schedule and emails only when a file newly diverges. On its first run all **21** deployed files were identical, which was the baseline we wanted to protect. The rule that follows from it is simple: the correct deploy is the git-driven script, not a manual copy.

## Step 4: verify what actually shipped

**Compare the served source with the local source, file by file.** After the add-on incident, our add-on deploy script fetches the code the platform is serving after the push and after the version is created, compares it with the local files, and stops before moving deployments if anything differs. On its first run the guard found a second contamination, on a different add-on, that we were not looking for.

**Read the body, not the status code.** We once reported that an API description and a documentation page on one of our servers were public, based only on a 200 status. Both responses were the single-page application's index page, served as a fallback for every unknown path. Asked directly, the service answered 401. A status code says a server answered, not what it answered.

**Check that the platform knows about the new code.** A scheduled function on a background-job platform was shipped after the last time the app had been registered with that platform. The code was in the deployed bundle, passed type checks and worked when called by hand, but it never ran on its schedule for **4** months. The **64** manual runs in the log were fully compatible with a function the scheduler had never heard of. Re-registering produced the first scheduled runs within **10** minutes; a step in the deploy pipeline now re-registers after every production deploy and fails loudly if the handshake is refused.

## The shell traps that turned a safe step into a live change

**A test command can change production.** An agent tested a temporary web-server configuration as root with the system binary. The test run recreated the server's temporary directories and gave them to the default unprivileged user, because the test file did not name one. The real workers could no longer write request bodies, so every large incoming webhook got a 500 for about five hours: **100,893** order-update webhooks failed and **84** orders never arrived. The application log showed nothing, because the requests never reached the application; the clue was a 500 with a **186**-byte body, which is the web server's own error page. Test configurations now run in a throwaway container.

**A semicolon does not inherit the failure of `&&`.** A one-line command chained `cd` into a new worktree with `&&`, then ran a patch after a `;`. Creating the worktree failed, the `cd` never happened, and the patch was applied to the live tree the deploy script publishes from. No deploy ran before we noticed and reverted it. Writing steps now live in a script with `set -euo pipefail` and a guard that checks the current directory before the first write.

**`ssh` inside a loop eats the list.** Without `-n`, `ssh` reads standard input, so the first iteration swallowed the rest of the file. A job prepared **three** batches and sent only the first, with no error. Use `ssh -n`, read the loop from a separate file descriptor, and count at the end: a loop that processes fewer items than it was given must exit non-zero. Test loops with at least **2** items.

**Code must never pass through a layer of shell quoting.** Sending a script through `ssh '...'` with a heredoc silently removed every single quote inside it; we hit this **three** times, the last time with SQL. Write the file locally, check its syntax, copy it with `scp`, then run it.

## Where we were wrong

We told the reviewer that the debugging probe had not reached users, based on `git log`. It could not have shown up there: the probe was never committed. Absence from git is not evidence of absence from production. The evidence is the source the server is serving.

Our own website deploy still ends by printing the status codes of **20** pages. For a static site a 404 is meaningful, but a 200 does not prove that the new content is there, which is the same lesson as the single-page fallback above. The honest description is that this step catches missing pages, not stale ones.

## Limits of this method

Dry runs, parity and served-source checks tell you what changed and whether it matches git. They do not tell you that the code in git is correct, and they do not cover side effects outside the deployed files, such as a migration that already ran or a test command that changed directory ownership. A parity check also only covers files it knows about; configuration edited directly on the server is invisible to it unless it is tracked.

## Checklist

**1. Dry run first.** Build from source, list copies and deletions, then pass `--apply`.

**2. Quiet tree.** No parallel workflow writing to the source; no uncommitted changes; recently modified files checked.

**3. Parity.** Server files match the main branch; deploy through the git-driven script, never a manual copy.

**4. Served content.** Compare served source with local; read response bodies; confirm the platform registered new functions.

**5. Shell hygiene.** `set -euo pipefail`, a directory guard before writes, `ssh -n` in loops, count processed items, no code through shell quoting, no test configurations on production hosts.

## Questions and answers

### What does dry run by default mean for a deploy script?

It means the script, when run without flags, only lists what it would copy and delete on the server and changes nothing. Publishing requires an explicit flag such as --apply. This matters most when the script deletes files on the server, for example rsync with --delete, because you read the list of deletions before anything is removed. Build from source first so the list reflects the sources, not stale files.

### What is a deploy parity check?

A deploy parity check compares every file deployed on a server with the same file in the main branch of the repository and reports any difference. It catches drift from files copied by hand, or fixes made directly on the server and never committed. Run it on a schedule and alert only when a file newly diverges, so the first clean run becomes the baseline you protect.

### How can I verify what was actually deployed?

Fetch what the server or platform is actually serving after the deploy and compare it with your local files, file by file, before you switch traffic or move deployments. Read response bodies rather than status codes. A successful push only proves the transfer worked; if another process was editing the folder at that moment, the uploaded content may not be what you intended.

### Why can a 200 response hide a missing or protected page?

Many web servers send every unknown path to a single-page application's index page, which returns 200. A route can therefore look public or present while the real service would answer 401 or 404. To judge whether a route exists or is open, read the body of the response and, where possible, query the service directly without the web server in front of it.

### How can a shell command accidentally change production during a deploy?

Common causes are a semicolon after a cd that failed, so later commands run in the live directory; ssh inside a loop reading the loop's input and processing only the first item; quotes stripped when code passes through ssh with a heredoc; and testing a web-server configuration as root, which can change ownership of its temporary directories. Use set -euo pipefail, directory guards, ssh -n and copied files.

## How to cite

**Cite this as:** Going Stellar (2026). *Safe deploys: dry-run by default, parity checks and verifying what actually shipped*. GOING STELLAR S.R.L.. https://goingstellar.ro/methods/safe-deploys-dry-run-parity-checks/

```bibtex
@misc{goingstellar2026safe,
  title  = {Safe deploys: dry-run by default, parity checks and verifying what actually shipped},
  author = {{Going Stellar}},
  year   = {2026},
  url    = {https://goingstellar.ro/methods/safe-deploys-dry-run-parity-checks/},
  note   = {2026-10-11}
}
```

Licence. Free to quote with attribution.

## Also available

- This page as HTML: https://goingstellar.ro/methods/safe-deploys-dry-run-parity-checks/
- Same article in Romanian: https://goingstellar.ro/ro/metode/deploy-sigur-dry-run-paritate/
