Shipping LaralCN-UI 0.3.0: three ways a deploy can lie to you
The release was tagged, the tests passed, and the live site was still broken. A gitignore line had frozen the deployed CSS eleven commits earlier, the server was running code from before the merge, and my own syntax highlighting took every documentation page down with a 500. What each failure looked like from the outside, and why the last one was the worst kind of bug.
This closes out the 0.3.0 run. The first post was removing Alpine, the second was the blocks and the docs site. This one is what happened when it met a server.
The code was fine. 87 tests, 731 assertions, every page verified by hand in a browser. Every failure below is about the distance between a correct repository and a running site.
Packagist reads tags, not fields
Small one first, because it wastes time. Both composer.json files in this project carry a version field, so bumping to 0.3.0 felt like editing those. It is not. Packagist derives the version from the git tag, and Composer will tell you so if you ask:
$ composer validate --no-check-publish
./composer.json is valid, but with a few warnings
- The version field is present, it is recommended to leave it out
if the package is published on Packagist.
The field is harmless when it stays in sync and misleading when it does not. git tag -a v0.3.0 is the release.
There is an ordering trap next to it. The docs site consumes the package from Packagist and its composer.lock is committed, while the server runs composer install --no-dev. Raise the constraint to ^0.3.0 before the tag is live and the lock is stale, so the deploy install fails. The constraint has to move after the tag exists, with composer.json and composer.lock committed together.
A gitignore line that froze the site
Then the blocks looked broken in production while working locally.
The symptom was strange enough to be useful. On /blocks/navbar-03/preview the mega menu markup was current, the click handler ran, data-state flipped to open, aria-expanded became true, and the panel stayed invisible at opacity: 0.
That split is diagnostic. Blocks are fetched from the published registry over HTTPS at runtime, so block sources came from main and were current. Everything rendering around them came from files on the server's disk and was not. New markup, old stylesheet, missing utility class.
The cause was eleven commits old. A commit titled "update .gitignore" had added /website/public/build/ to both ignore files and untracked the built assets. The deployment ships assets through git, because the plan was always to build locally and deploy the output. From that commit onward, git pull stopped updating any CSS or JavaScript on the server. The deployed stylesheet had been frozen for weeks and nothing pointed at it, because the site kept working: the frozen build was correct for the code that shipped alongside it, right up until it was not.
$ git ls-files website/public/build | wc -l
0
Two lines of documentation had said the assets were committed the entire time.
The fix was un-ignoring the directory and committing the build. The lesson is that "we deploy X through git" and "X is in .gitignore" is a contradiction a repository will hold quietly for as long as you let it.
Reading the server, not the code
The next report was that the og:image tags were missing on every page. They were in main, they rendered locally, and they were absent live.
When something is present in the repository and absent in production, stop reading the repository. One request settled it:
$ curl -s -o /dev/null -w "%{http_code}" https://laralcn-ui.abdulkadersafi.com/og-image.png
404
That file was committed in the release. A 404 means the server does not have the commit, which makes every other theory about Blade caching or CDN behaviour irrelevant. The served HTML still contained x-data on the <html> element and an [x-cloak] style block, neither of which exists anywhere in main. The server had simply never pulled.
A useful habit came out of this: pick one file that exists only in the new commit and request it. It is a single yes or no about what the server has, and it costs one command.
The one that was my fault
Server updated, and most of the site started returning 500.
The command "'node' 'shiki.js'" failed.
Exit Code: 127 (Command not found)
sh: 1: exec: node: not found
Server-side highlighting was my change from the previous post. Node is installed on that box through nvm, which puts it on the shell PATH through a profile script. PHP-FPM does not read your shell profile. So node existed for me over SSH and did not exist for the process rendering the pages, and every page carrying a code block died. The homepage survived because it is the only page with no code samples, which is why it looked like a routing problem for a while.
The fix is two-sided. On the server, expose node where Shiki looks:
sudo ln -sf "$(which node)" /usr/local/bin/node
In the code, stop letting decoration be load-bearing:
try {
$highlighted = Shiki::highlight(code: $code, language: $language, theme: 'github-dark');
Cache::forever($cacheKey, $highlighted);
} catch (\Throwable) {
$highlighted = null;
}
Falling back to plain, readable code, and retrying next request rather than caching the failure forever.
My first version of that catch called report($e), which made things worse. storage/logs was not writable by www-data on that server, so logging the caught exception threw a fresh one and produced the same 500 with a longer stack trace. The catch is now deliberately silent. A missing syntax highlighter is cosmetic and must never be able to take a page down, including through the act of complaining about itself.
Social previews
One last small thing, since it is easy to get wrong. The og:image was the 2400x1260 master at 268KB while the tags declared 1200x630. Crawlers are unreliable with large previews and with dimensions that contradict what is declared, so the served file is now actually 1200x630 at 101KB.
If a preview still looks wrong after that, it is probably cached rather than broken. WhatsApp and Facebook cache per URL for a long time, and they will happily keep serving a scrape from before the tags existed. Sharing a URL with a query string it has never seen forces a fresh fetch.
What I would do differently
Every one of these was invisible from inside the repository. The tests passed the entire time, and they were right to: none of them were wrong about the code.
So the check I actually needed was not another test. It was one command asking the running server what it has, and I now keep it next to the deploy steps.
composer require --dev safi/laralcn-ui. Docs at laralcn-ui.abdulkadersafi.com, code on GitHub, MIT.
Building scalable systems and developer-first tools. Lead Software Engineer at DSRPT.
Frequently asked
-
Push an annotated git tag. Packagist reads the version from the tag, not from a version field in composer.json, and Composer warns that the field should be left out for packages published on Packagist. If another repository in the same project consumes the package from Packagist with a committed lock file, raise its constraint only after the tag is live, and commit composer.json and composer.lock together.
-
Because nvm puts node on your PATH through a shell profile script, and PHP-FPM does not read your shell profile. A library that shells out to node therefore finds it when you test by hand and fails in the request, with exit code 127 and node: not found. Symlinking node into /usr/local/bin fixes it, and the calling code should also degrade gracefully so a missing binary cannot return a 500.
-
Pick one file that exists only in the new commit and request it from the live server. If it 404s, the server does not have the commit and every theory about caches, compiled views or CDNs is irrelevant until it does. It is one command and a yes or no answer, and it is much faster than re-reading code that is already correct.