A release is complete when the required public-edge checks agree with the artifact you chose, not merely when upload exits zero. This tutorial connects build, check, publish, and verify, while distinguishing local checks from deployment changes. Publishing changes the server; only the final probes are read-only network observations.
The commands assume Bash, GNU find/sort/sha256sum, rsync, Python 3, and the project’s pinned Hugo. Run build steps from the repository root. Before touching a live host, rehearse the directory-switch example in a disposable directory. Know the actual serve root, who owns deployment, the previous release target, and the rollback procedure. The /srv layout below is an example requiring an already configured deployment, not something this repository automatically installs.
1. Build one artifact from recorded inputs
Start from a clean source revision. Run the project build and record the installed generator version:
set -eu
git status --short
git rev-parse HEAD
hugo version
make build
An empty short status supports a clean-tree claim; the command itself does not enforce one. Inspect any changes before calling the build a release of that commit. make build writes data/buildinfo.toml with the short commit and current UTC build time, then generates public/. The rendered marker identifies the recorded inputs; it is not proof that a dirty checkout contained only that commit.
Pinning Hugo and the vendored theme reduces drift, but the build-time field means ordinary builds at different times need not be byte-identical. Build the chosen release once, validate it, and transfer that exact artifact rather than rebuild independently on the server.
2. Check the complete offline contract
make check checks generated visuals, offline-pack freshness, internal links, script-free output, source links, and downloadable checksums for the current artifact. For the full repository gate, use:
set -eu
make verify
make check-upstream
The full gate also runs regression tests, bilingual-post checks, and review-ledger freshness, then rebuilds and checks output. Therefore create the release manifest only after the last build. Build success and a current review ledger do not establish technical correctness of every article or prove the public server uses the intended headers. Page counts and timings vary; retain actual output rather than comparing to a copied example count.
3. Name the artifact bytes in a manifest
Before hashing, verify that public/ contains only directories and regular files, and stop all writers until staging is complete. The commands below do not create a snapshot or check for every special-file type. Keep the manifest outside public/ so it cannot hash itself. NUL-delimited filenames preserve spaces and other shell-sensitive characters:
set -euo pipefail
manifest="$(pwd)/release.sha256"
(
cd public
find . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum
) > "$manifest"
(cd public && sha256sum -c "$manifest")
sha256sum "$manifest"
GNU checksum lines contain the digest before the filename. A successful sha256sum -c verifies listed files, not that no extra files exist. A new empty release directory avoids stale destination files, but does not detect extra source files added after hashing. To establish complete regular-file inventory parity, recompute the staged manifest with the same path/sort/hash rules and compare the full lists before selection. Inspect symlinks separately: find -type f does not include them, and serving an unintended symlink can expose files outside the intended root. Keep the manifest and its own digest with the release record; a checksum proves consistency against that manifest, not authenticity of an untrusted manifest.
4. Stage and select a complete release
Assume Caddy already serves /srv/www/example-site/current, which is a symlink into releases/, with correct ownership and read permissions. The releases parent must already exist. Serialize deployers and choose a unique release ID. Run this block as a standalone Bash script on the deployment machine, from the directory containing the validated public/ and release.sha256. Check the artifact’s ownership and readable permissions against the deployment policy before running it. It establishes its own manifest path and fail-fast policy; a failed precondition, copy, or checksum must stop before pointer selection. It refuses symlinks inside the staged artifact and is a mutating deployment step.
set -euo pipefail
base=/srv/www/example-site
manifest="$(pwd)/release.sha256"
test -f "$manifest"
test -d public
release_id=20261015T120000Z
release="$base/releases/$release_id"
test -L "$base/current"
previous=$(readlink "$base/current")
mkdir "$release" # Fail if this release already exists.
rsync -a public/ "$release/"
(cd "$release" && sha256sum -c "$manifest")
links=$(find "$release" -type l -print -quit)
if [ -n "$links" ]; then
echo "Refusing symlinks in the staged artifact" >&2
exit 1
fi
# Ownership and readable permissions must match the deployment policy.
python3 - "$base" "$release_id" <<'PYTHON'
from pathlib import Path
import os, sys, uuid
base = Path(sys.argv[1])
release = base / "releases" / sys.argv[2]
current = base / "current"
if not current.is_symlink() or not release.is_dir():
raise SystemExit("Expected current symlink and a complete release directory")
pending = base / (".current-" + uuid.uuid4().hex)
pending.symlink_to(os.path.relpath(release, base), target_is_directory=True)
os.replace(pending, current)
print("selected:", os.readlink(current))
PYTHON
printf 'Previous target: %s\n' "$previous"
The symlink replacement uses one same-filesystem rename because the pending link and current share a parent. An ordinary mv new existing-directory does not exchange non-empty directories. Do not convert an existing real-directory serve root with this snippet; plan that migration separately. Validate the actual server’s root resolution and filesystem behavior in staging.
The old target stays on disk, but that alone does not make an old asset URL reachable after switching. Retain fingerprinted asset bytes in the newly served namespace or a stable asset store. Multiple browser requests can span releases, and caches may keep old HTML. Atomic namespace replacement is not cross-request snapshot isolation or a power-loss durability guarantee.
5. Verify delivered bytes and policy
After switching, compare a sampled public body with the corresponding artifact file, using a fixed representation selection:
set -euo pipefail
url=https://www.xbcatm.com/
status=$(curl -sS --max-time 20 -H 'Accept-Encoding: identity' \
-D served.headers -o served.html -w '%{http_code}' "$url")
test "$status" = 200
cmp public/index.html served.html
sh public/downloads/check-http-headers.sh "$url"
Run this block from the same artifact directory used for staging. The header helper is the trusted copy inside that artifact; a source checkout is not required for this step. cmp succeeding establishes exact parity for this homepage sample. An unexpected redirect, proxy-generated 200 error page, CDN transformation, or encoded response needs investigation. Inspect served.headers, including Content-Encoding, cache age, CSP, and content type. The header helper prints responses and final status; it does not enforce all your expectations.
Repeat for Chinese content, a nested article, CSS, an SVG, a download, redirects, and the error route. Document the URL-to-file mapping, including directory indexes. The shown listed-file check establishes consistency for those files, not a complete staged inventory or what all public cache nodes deliver. A complete edge claim requires broader retrieval and representation-aware checks; a few probes remain samples.
If a post-switch check fails, the new release is still selected until you act. Use a new temporary symlink targeting the recorded previous value, atomically replace current again, and repeat the probes. Do not merely move the new tree away, and do not delete evidence or the previous release while diagnosing.
6. Check TLS identity and the expiry window separately
From the repository root, the expiry helper is:
sh static/downloads/check-tls-expiry.sh www.xbcatm.com 30
It extracts a certificate, prints dates, and checks the remaining expiry interval. It is not a full TLS identity test: its current implementation does not make hostname/trust verification failure fatal. Follow the public-edge trust tutorial for an explicit verified handshake and SAN check. Do not infer trust-chain or revocation success from “valid for at least 30 days.”
A near-expiry certificate is a separate release concern. Preserve the actual notAfter, trust result, and client environment rather than copying a historical issuer or date into the release log.
7. Include the DNS cache window when DNS changes
A content-only release need not change DNS. If the host or edge changes, preserve service at the old destination while caches expire. Lowering TTL at the moment of the change does not shorten entries already cached with the old TTL. Resolver serve-stale behavior, negative caching, and client caches can complicate the window; TTL is not a universal deadline by which every client has moved. CAA changes affect issuance policy, not routing to the new content.
8. Keep a rehearsed restore path
The previous directory is useful for fast rollback, but it is not an independent backup. A disk failure, accidental cleanup, or compromised deployment account may affect every local release. Retain source, build inputs, configuration, and an appropriate separate artifact backup; rehearse restoring them in isolation. One successful rehearsal is evidence about that recovery path at that time, not a permanent guarantee for later changes.
9. Record the outcome precisely
Keep the source revision and clean-tree status, build metadata and tool versions, manifest digest, selected and previous targets, probe timestamps, HTTP/TLS observations, and any rollback. State whether verification covered samples, all mapped URLs, or multiple vantage points. If a gate failed, record the failure and recovery rather than “it worked.”
Before selection, a failure can leave the old release untouched. After selection, recovery is an explicit operation with its own verification. This distinction makes the release path auditable without pretending every failure automatically restores the old tree.
Sources
rsync manual, archive copying and source trailing-slash semantics, checked 2026-10-15.
RFC 8767: Serving Stale Data, DNS serve-stale behavior beyond TTL expiry, checked 2026-10-15.
GNU sha256sum manual, Debian package, checksum output and verification flags, checked 2026-10-15.
Python
os.replace, atomic replacement and filesystem limits, checked 2026-10-15.Caddy
file_server, root and symlink handling, checked 2026-10-15.curl manual, status capture, headers, and content decoding, checked 2026-10-15.
OpenSSL
s_client, explicit certificate verification, checked 2026-10-15.Reproducible Builds: definitions, artifact identity versus reproducibility, checked 2026-10-15.