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.

Build and validate one exact artifact, create its manifest after the last build, and verify a new empty stage. Preselection failure stops with the old release unchanged. Selection replaces the pointer. Afterwards, public responses are sampled against artifact files; failure leaves the new release selected until explicit rollback to the recorded previous target and repeat probes. Atomic namespace replacement does not make multiple requests coherent or guarantee power-loss durability. Disk checks, served bytes, TLS identity, expiry, DNS cache changes and independent backup each require their own evidence.
The pointer switch changes the failure boundary. Before it, stop with the old release; after it, roll back explicitly and verify again. Disk checks and edge samples establish different facts.

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