DEV Community

Mohamed
Mohamed

Posted on

A five-file Laravel release when the production worktree has drifted

On 5 October I shipped a design refresh to the storefront I run: one stylesheet and four Blade templates, the hero, catalog, operations and homepage views. Nothing else moved. No migrations, no seeding, no dependency changes, no database edits.

The interesting part was not the design. It was that the production worktree was not in the state my branch said it should be in, and I did not want a whole-branch deployment to decide what those files became.

Why not a normal git deploy

The production checkout was at a different commit from my working copy. Its stylesheet carried a hash that I had pinned from an inspected snapshot, not derived from a commit. The production branch was left unchanged on purpose, because this was a scoped release over an existing production worktree.

So the release became a list of five paths, each with a before_sha256 and an after_sha256, written into a manifest. The local release script refuses to build the payload unless the four production templates match the committed versions in my source checkout and the stylesheet matches the hash I inspected. If either check fails, nothing is uploaded.

The payload is a tar of exactly those five files, and the manifest records the production commit hash, the source commit hash, and the two digests per file.

What apply checks before it writes anything

The remote script takes one of three modes: apply, verify or rollback. Everything runs inside an exclusive, non-blocking file lock, so a second release on the same checkout fails immediately instead of queueing. It is a file lock on one machine, which means it serialises releases on that host and nothing beyond it.

apply runs a chain of refusals before it touches a file:

  • If a backup.json already exists in the release directory, the release was already attempted and the script stops; a retry needs a new release directory.
  • Production HEAD must equal the commit recorded in the manifest.
  • Every target file's current hash must equal its before_sha256.
  • Every tar member must be a regular file named in the manifest; anything else aborts.
  • The set of members must equal the set of manifest entries, or the payload is incomplete.
  • Each member's content must hash to its after_sha256.
  • Plain .php files get php -l; .blade.php files are skipped. In this release the lint step did nothing, since the five files were Blade templates and a stylesheet.

Target paths are also checked: no absolute paths, no .. segments, no symlinks anywhere between the root and the file.

Only then does it back up each original together with its file mode, write backup.json, and record the phase in state.txt.

The write itself

Each file is written through one small function:

def atomic_write(p, data, permissions):
    p.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
    temporary = p.with_name(p.name + '.storefront-release-tmp')
    created = False
    try:
        with temporary.open('xb') as out:
            created = True
            out.write(data)
            out.flush()
            os.fsync(out.fileno())
        os.chmod(temporary, permissions)
        os.replace(temporary, p)
    finally:
        if created:
            with contextlib.suppress(FileNotFoundError):
                temporary.unlink()
Enter fullscreen mode Exit fullscreen mode

The temp file lives in the same directory as the target so os.replace is a rename on the same filesystem. A reader sees either the old file or the new one, never a half-written one.

Right before each write, the script hashes the target again. The pre-flight check already passed, but the backup step and the write step are separate moments, and the error for this case is named for what it means:

if digest(target(rel)) != entries[rel]['before_sha256']:
    raise RuntimeError('Concurrent production edit: ' + rel)
atomic_write(target(rel), payload[rel], saved[rel]['mode'])
Enter fullscreen mode Exit fullscreen mode

After the five writes it runs php artisan config:cache and php artisan view:cache, logging both to a file in the release directory and raising on a non-zero exit. As the Laravel deployment docs describe them, config:cache combines the configuration files into a single cached file and view:cache precompiles the Blade views so they are not compiled on demand.

Then it hashes every file one more time and compares against after_sha256. Only if that passes does it write applied-awaiting-fpm-reload to state.txt and print reload php8.5-fpm.

Rollback is guarded the same way

Any exception inside the apply block triggers a rollback, and the exception is re-raised afterwards so the failure is still visible.

Rollback refuses to overwrite a file whose current hash is neither the before hash nor the after hash:

for rel, item in entries.items():
    current = digest(target(rel))
    if current not in (item['before_sha256'], item['after_sha256']):
        raise RuntimeError('Concurrent edit; refusing rollback overwrite: ' + rel)
Enter fullscreen mode Exit fullscreen mode

That rule matters more than it looks. A rollback that blindly restores the backup would be a second way to lose a change made outside the release, which is the exact thing the whole release was built to avoid. Each backup is hashed against before_sha256 before it is restored, restored with the saved mode, and the caches are rebuilt again.

FPM reload is a separate step

The remote script does not reload PHP-FPM. The local driver runs the reload after apply, checks the service is active, and only then calls verify, which re-hashes the five files against after_sha256. The log for this release shows the backup, both cache rebuilds, the five applied files, active, and verified_files: 5.

The release reloads FPM rather than waiting on timestamp validation. The PHP manual says that with opcache.validate_timestamps disabled you must reset OPcache or restart the web server for changes to the filesystem to take effect.

The read-only browser check

Hash verification proves the bytes landed. It does not prove the pages render. So the last step is a Playwright script that is deliberately unable to submit anything.

await page.route('**/*', route => {
  const request = route.request(), url = new URL(request.url());
  if (request.method() !== 'GET' && request.method() !== 'HEAD') return route.abort();
  if (!['seosellers.com', 'www.seosellers.com',
        'fonts.googleapis.com', 'fonts.gstatic.com'].includes(url.hostname)) return route.abort();
  return route.continue();
});
Enter fullscreen mode Exit fullscreen mode

Every request that is not GET or HEAD is aborted, and every request to a host other than the site or Google Fonts is aborted. Form submissions and third-party tracking cannot fire from inside the check.

It visits 14 public routes at 1440px and 390px, which is 28 renders. Each one must return HTTP 200, contain exactly one h1, have no horizontal overflow, no broken images, no uncaught page errors, and a _token field on every POST form. Images are forced to eager loading and decoded, and fonts are awaited, before the measurements are taken.

On the homepage it goes further: clicking the pages filter must leave exactly one visible product card, and at 390px the mobile menu must open and then close on Escape. Every result is written to a JSON file next to the screenshots. All 28 passed.

What this release did not prove

Live payment and mail delivery were not exercised. The git branch was not updated, so the next full deployment from the branch must carry these five changes or it will undo the design; that is a debt, and it is written in the release notes so it is not forgotten.

The hashes pin a moment, not correctness. They prove the file on disk is the file I inspected, before and after. Whether that file is right is what the local tests, 50 of them with 382 assertions, and the 28 browser renders are for.

The writes are atomic per file, not for the whole release. A request can see a mixture of old and new files while the five replacements are in progress.

And the lock is per host. It serialises releases that go through this script on that machine. The hash checks detect changes present when they run, but an editor that ignores the lock can still write between a check and a replacement. The script reduces that risk; it does not eliminate the race.


I build and run SEOSellers, a platform that publishes pages for other people's businesses; this release was its storefront.

Top comments (0)