Staging is not production: how this site ships
This website is built by a pipeline you can read: the workflow is
.forgejo/workflows/ymrtech-deploy.yml in the same public repository
as the pages themselves. It is short enough to read in one sitting, and it
contains one design decision worth writing about, plus one incident that
produced it.
Three triggers, two environments
| Event | What runs | What it writes |
|---|---|---|
| Pull request | the full gate, plus a throwaway preview of the branch | nothing outside the preview root |
Push to main |
the full gate again | staging only |
| Manual dispatch with promote to production | the full gate a third time | production |
main is the release branch for both environments. They differ by trigger,
not by branch: merging to main automatically verifies the exact commit that
will later go to production, and production only moves when a human dispatches
the promotion, which re-runs the entire suite before touching the prod docroot.
The gates are the same in every job — icon font coverage, signed audit artifacts,
the Jekyll build, a check that the contact form is actually visible in the
rendered page rather than hiding in an HTML comment, htmlproofer over the built
site, a fragment-resolution check, the palette check, and the writing-content
check that guards these posts. Each job is self-contained, so a gate added in one
place has to be added in all four. That is a cost, and it is paid deliberately: a
job that depends on another job’s artifacts can be skipped into a false green.
The day a pull request overwrote staging
The workflow’s own comments keep the scar tissue. Originally there was a single
job, and it ran its rsync on pull_request events too. The consequence: opening
or updating a PR — any PR, from any branch — replaced /srv/ymrtech-stage with
that branch’s build. Staging stopped meaning “what main verified” and started
meaning “whatever was pushed most recently”, which is the same as meaning nothing.
The fix is one condition on the deploy job — staging is gated to push and
explicit workflow_dispatch, and the cleanup that removes a preview is gated to
pull_request events instead. That one line is the difference between a staging
environment and a scratch directory.
Previews get a path, not a hostname
Rather than give each branch a subdomain, a PR gets a path:
stage.ymrtech.com/preview/pr-<N>/, built with --baseurl /preview/pr-<N>. That
works because every internal link and asset reference in this site is generated
through a Jekyll filter that applies the site’s baseurl — relative_url for
links, asset_url for the handful of design assets, which adds the file’s own
content hash on top. There is nowhere to hardcode a root-relative path and have
it render, which is exactly the kind of property you fail to maintain until the
day you need it.
The preview root is a separate tmpfs from the staging docroot, so a staging
deploy (rsync --delete) cannot erase a preview, and a preview cannot overwrite
staging. It is deleted when the PR closes. And because it is unreviewed branch
content published on a real domain, the pipeline verifies the served URL after
publishing it: it polls for HTTP 200 and then requires the X-Robots-Tag: noindex
and Cache-Control: no-store headers to be present. If those headers are missing,
the job fails — the assumption being that a response without them is not the
preview route that was supposed to answer.
The bits that only show up in production
Two failures are worth keeping in mind as arguments for testing the real artifact rather than the source:
- 327 missing files. The first real preview run failed html-proofer with 327
“does not exist” errors, because the site was built with a
/preview/pr-<N>baseurl while the proofer resolves internal references against the docroot on disk, where no such directory exists. The fix is--swap-urls, and the workflow now documents why it takes two replacements rather than one: with a single pair the prefix is rewritten to/, producing protocol-relative URLs like//assets/x, which the proofer also rejects. All three variants — no swap, one pair, two pairs — are reproduced in the comment, along with which one is clean. - A token that cannot be baked in. The contact endpoint rejects unauthenticated posts, so the form needs a token — but this repository is public and a token in the source is a token you cannot rotate without a rebuild. The deploy writes it from a host-readable secret file into a same-origin JSON endpoint at deploy time, and if that file is missing the endpoint stays locked and the form offers a mailto fallback instead of failing silently.
Why this is here rather than in a wiki
Because it is the same argument as the audit page: a delivery claim you cannot inspect is a claim about intentions. The workflow is public, the failure modes are documented next to the code that caused them, and the pipeline that builds this page is the one that would have caught me getting it wrong — the gate for these posts runs in all four jobs, and it fails the build if a post’s stable slug and its rendered campaign tag stop agreeing.
For the infrastructure underneath it — the hosts, the runner, the deploy health gate and the automatic rollback — see how the stack is built.