Deploy to an environment and prove the release is the one answering - #16
Conversation
The release tooling could name a single deploy root, which was enough while there was one mirror and will not be once the VPS grows a staging site and a production site. Nothing here could address two at a time. The blocker was not the obvious one. make-release.sh sourced secrets/.env with `set -a`, which exports every assignment in the file and overwrites a DEPLOY_ROOT the caller exported first, so sourcing a different environment file silently deployed to the original root. On a host serving two sites that failure is not an error, it publishes to the other site. ENV_FILE now selects the file, the first argument still overrides the root because it is read afterwards, and a named file that does not exist is a hard failure rather than a fall-through to whatever the ambient environment holds. check-live-urls.sh could not authenticate, so it could not check a site that keeps its auth gate on. Staging keeps its gate on deliberately: it serves a byte-identical copy of the public site, and an open one is a duplicate handed to every crawler, which is the one thing a migration about preserving URLs should not do. A Pangolin resource access token opens it instead. The credential travels in a mode-600 curl config file rather than in -H arguments. A command line is readable in ps for the life of the process and this runs 1,245 of them, and the config file is also the only form that survives the `export -f` the parallel checks run under, since bash cannot export an array. It is sent to the base URL's own origin and nowhere else, so a rule that one day redirects off-site cannot carry it away. A preflight request runs first, because behind an auth gate a wrong token fails every URL and the output then reads as a site that has vanished rather than as a bad credential. Two decisions the docs asserted the opposite of, both now recorded with their reasoning rather than silently reversed. One deploy key covers both environments instead of one per environment: the split pays off only where the two keys never share a machine, and both sit on one workstation and in one secret store. The cost is that the rrsync forced command can no longer separate the environments, so the deploy roots move under a common parent and one pinned command covers both. And staging takes the VPS wildcard at blog.vps.insanegenius.net, which needs no new certificate and closes the open FQDN decision. Verified against both local mirrors, which are two containers in a sibling repository's new blog stack: 1,245 URLs honored on each, each baking its own base URL, neither disturbing the other's release history. Each new gate was demonstrated failing before being trusted, per the rule in TODO.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A release reaches a host by rsync and no restart, so nothing outside the verifier observes whether the rules that answer are the rules shipped. Content follows the current symlink per request and is live immediately, while rules wait on an in-process config reload. A container that never reloads therefore serves new content under a previous release's redirects, and the URL contract passes against a config that was never deployed. The bundle stamps its own version, substituted at install time, and the check compares it before requesting any of the 1,245 URLs. It waits rather than sampling once, because the reload is asynchronous and a check run straight after a deploy races it. The timeout still catches a container that never converges. Caddy also stops watching permanently after one failed config load, without logging that it has given up, so anything that breaks the symlink even briefly ends that container's ability to pick up releases until it is restarted. The failure reports as healthy, which is why the verifier names both causes. Two more headers identify the environment, since every environment serves one bundle on one port and only the container distinguishes them. Robots defaults to the value that is harmless on production, because the failures are asymmetric: an unset staging container is still behind its auth gate, where production inheriting noindex would deindex the site silently. trusted_proxies takes its ranges from the container, and excludes the bridge gateway. Trusting the subnet trusts the host, which was verified by forging a client address from it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR extends the blog’s release tooling and deployment workflows to support distinct staging/production environments, and adds an end-to-end verification step that proves the deployed config (not just content) is what’s currently answering requests.
Changes:
- Add per-environment configuration selection (
ENV_FILE) and safety guards indeploy/make-release.sh. - Add deploy verification headers (
X-Blog-Release,X-Blog-Env,X-Robots-Tag) and strengthenchecks/check-live-urls.shto preflight environment/release correctness (including async reload waiting and auth-gate support). - Introduce GitHub Actions deploy workflows (
deploy-site.yml+ reusabledeploy-site-task.yml) that validate, deploy, and verify per environment.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| TODO.md | Updates deployment planning notes/decisions reflecting multi-environment deploy. |
| spec/secrets.json | Documents staging-only Pangolin access token secrets and updated environment secret rationale. |
| README.md | Refreshes project links/badges and clarifies issue/reporting links. |
| OPERATIONS.md | Documents the multi-environment model, reload/watch behavior, auth-gate verification, and ownership boundaries. |
| deploy/README.md | Expands the deploy/serve contract (watch reload, env identification, trusted proxies, auth-gate variables). |
| deploy/make-release.sh | Adds ENV_FILE selection, remote-root guardrails, and release stamping into shipped Caddyfile. |
| deploy/env.example | Updates environment template for multi-env usage and adds Pangolin + verification variables. |
| deploy/Caddyfile | Adds trusted proxy config and response headers for env/robots/release identification. |
| checks/check-live-urls.sh | Adds auth-gate support, preflight diagnostics, environment assertion, and release/reload convergence check. |
| .github/workflows/deploy-site.yml | Adds a dispatcher workflow to validate + deploy to a chosen environment with gating rules. |
| .github/workflows/deploy-site-task.yml | Adds the reusable deploy job: build bundle, rsync upload/flip, then verify live contract. |
A relative ENV_FILE resolved against the caller's working directory while the default resolved against the repo, so the same name meant different files depending on where the script ran. The container comment described a restart as the way a config change goes live, which the in-process reload replaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
README.md:89
- This section still says local configuration comes from
secrets/.env, but the repo now supports one file per environment selected viaENV_FILE(e.g.secrets/staging.env). Updating this avoids steering operators toward a single-environment setup that no longer matches the deploy tooling/docs.
The deploy root and the base URL come from an untracked `secrets/.env`, copied from [deploy/env.example][env-example]. The whole `secrets/` directory is gitignored, so host-specific values stay out of the published history.
spec/secrets.json:30
- This manifest lists PANGOLIN_ACCESS_TOKEN_{ID,} under
environments.secrets, which implies they must exist for both staging and production, butsecretsNotesays they are staging-only and absent in production. As written, this is internally inconsistent and will mislead any secrets-name audit that treatsenvironments.secretsas required per environment.
"secrets": [
"DEPLOY_SSH_PRIVATE_KEY",
"PANGOLIN_ACCESS_TOKEN_ID",
"PANGOLIN_ACCESS_TOKEN"
],
The manifest listed the access token beside a credential every environment carries, which reads as required in both. A name audit would then report it missing from production, where it is deliberately absent. The README described configuration as coming from one file, which the per-environment selection replaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both suppressed comments were valid and are fixed in aff8581.
That second one is the more useful catch, because this repo has a rule requiring exactly the sweep I did not do: when a behavior changes, grep for prose asserting the old one, since no linter flags a claim that is merely untrue. I updated Noting for the record that these two arrived in the review body under |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (2)
deploy/make-release.sh:154
- The release-stamp substitution only fails when the placeholder remains after
sed. Ifdeploy/Caddyfileis edited in the future and the@@RELEASE@@placeholder is removed entirely, this script will silently produce an unstamped bundle, andcheck-live-urls.shwill later fail with a misleading “watcher is dead / no X-Blog-Release header” message. Add an explicit pre-check that the placeholder exists before attempting substitution.
# Stamp the release into the config it ships with, so a response names the rules answering.
# A stale config otherwise passes the URL contract against rules that were never shipped.
sed -i "s/@@RELEASE@@/$VERSION/" "$STAGE/Caddyfile"
if grep -q "@@RELEASE@@" "$STAGE/Caddyfile"; then
echo "release stamp was not substituted into the shipped Caddyfile" >&2
exit 1
fi
.github/workflows/deploy-site-task.yml:33
inputs.environmentis treated as an arbitrary string (used for the GitHub Environment name and interpolated into remote rsync paths).deploy-site.ymlconstrains the workflow_dispatch UI, butdeploy-site-task.ymlcan also be invoked by other workflows; an unexpected value can lead to confusing new environments (no secrets) or path injection on the remote forced-command root. Add an explicit allowlist check at the start of the job steps.
environment: ${{ inputs.environment }}
sed reports success when it matches nothing, so a Caddyfile that lost the placeholder shipped unstamped and the live check then blamed a dead config watcher for a bundle that never carried a release id. The environment name selects a GitHub Environment and lands in a remote path, and a workflow_call caller is not bound by the dispatch choice list. It is asserted in its own job, because the environment binding resolves before any step in the deploying job runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both suppressed comments were valid and are fixed in 4ba6bb3.
The placeholder is now asserted in the source before substituting. Demonstrated both directions:
Asserted against an allowlist, in its own job that the deploying job needs, because a job-level Both findings again arrived as suppressed comments in the review body. I checked for those this round, and initially misread the result: the REST API reports the reviewer as |
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (3)
checks/check-live-urls.sh:69
- The redirect check accepts both 301 and 308, but the failure message says "expected 301". This can mislead debugging (especially if the contract is later updated to prefer 308s).
case "$code" in
301 | 308) ;;
*)
echo "redirect $url expected 301, got $code" >>"$FAILED"
return
deploy/make-release.sh:24
- The usage text still implies the deploy root only comes from the ambient environment or secrets/.env, but the script now supports per-environment files via ENV_FILE. Updating the message will prevent confusion when a user relies on ENV_FILE and sees usage output on failures.
# The deploy root and the base URL are the only host-specific values, and they pair per environment.
# ENV_FILE selects the environment, because `set -a` overwrites a value the caller exported.
# The first argument overrides the root, being read after this.
DEFAULT_ENV_FILE="$REPO/secrets/.env"
ENV_FILE="${ENV_FILE:-$DEFAULT_ENV_FILE}"
deploy/README.md:54
- This section first instructs copying env.example to
secrets/<environment>.env, but the default file the scripts read whenENV_FILEis unset issecrets/.env. A reader who follows only this first instruction for a single-environment setup could end up withsecrets/production.envthat is never read, leading to confusing failures.
The deploy root and the base URL are the only host-specific values, and they pair per
environment. Copy [`env.example`](./env.example) to `secrets/<environment>.env` and set both.
`secrets/` is gitignored as a whole directory, so a value naming one machine cannot reach a
public repo by being added to a file nobody remembered to ignore. CI passes them explicitly
The redirect failure named one status while the check accepted two, and the usage text described a deploy root the environment file selection replaced. The setup instruction named only the per-environment form, so a single environment produced a file nothing reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All three suppressed comments were valid and are fixed in 0f03139.
These three are all the same defect as the two before them: I changed how configuration is selected and updated the places I was editing, without sweeping the prose that described the old behavior. This repo has a rule requiring that sweep precisely because no linter catches a claim that is merely untrue, and three consecutive rounds of your findings have been instances of it. |
The base URL has its trailing slash stripped, so a prefix test also matched a host that merely starts with it. A redirect to a lookalike registered as an attacker's subdomain would have received the credential. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Valid, and the best finding of this review. Fixed in 3cf9c34. The base URL has its trailing slash stripped at the top of the script, so the prefix test accepted any host merely beginning with it. A redirect to a lookalike an attacker controls would have received the credential. Demonstrated against the old and new comparisons: The match now requires an origin boundary, accepting the base exactly or followed by Worth naming why this one is sharper than a normal prefix bug: the guard existed specifically to keep the credential on its own origin, so it was the one line where a near-miss was worth nothing. It also reads as correct, which is why it survived review by me and by a reader who knew what it was for. Verified the tightened check does not reject legitimate destinations, using the run that actually sends the token: against the live staging site behind the auth gate, all 1,245 URLs pass, and that path follows every one of the 917 redirect destinations rather than trusting their status codes. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (1)
deploy/make-release.sh:60
- VERSION is treated as an arbitrary string but is used as (1) a directory name under "$ROOT/releases/$VERSION", (2) the current symlink target ("releases/$VERSION"), and now (3) a literal substitution into the shipped Caddyfile. A crafted value containing '/', '..', or other special characters can break the deploy layout (or traverse outside releases/) and can also break the Caddyfile stamp substitution.
# CI passes the version so a release directory traces back to a commit rather than to a clock.
VERSION="${2:-$(date -u +%Y%m%d-%H%M%S)}"
The value becomes a directory name, a symlink target, and a sed replacement, so a separator, a traversal, or a substitution metacharacter would place the release outside releases/ or corrupt the release stamp. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Valid. Fixed in 421737e. You are right that the value reaches three sinks with different rules, and the Tested every shape: The last three are the forms actually used: the timestamp default, an NBGV version, and a short commit SHA. Both mirrors still pass |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (1)
deploy/make-release.sh:171
sed -iis not portable (BSD/macOSsedrequires an argument to-i), but this repo’s governance states development is cross-platform by default (Windows/macOS/Linux). Usingsedwithout in-place editing avoids local runs failing on macOS when stamping the Caddyfile.
sed -i "s/@@RELEASE@@/$VERSION/" "$STAGE/Caddyfile"
if grep -q "@@RELEASE@@" "$STAGE/Caddyfile"; then
echo "release stamp was not substituted into the shipped Caddyfile" >&2
exit 1
fi
The scripts use GNU and bash 4.4 constructs that a stock macOS lacks, and the platform rule requires a narrowed platform to be recorded so it reads as a dependency ceiling rather than an oversight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Declining the change, acting on the premise. Recorded in fd9ffb0.
Any one of those fails a stock macOS run. Fixing the newest of the four would make the script look portable while still not running there, which is worse than the current state, because it invites someone to trust it. The rule you are citing has a second clause that the repo was not honoring: a narrowed platform is recorded, so it reads as a deliberate dependency ceiling rather than an omission. That is the real finding here and it is now in The narrowing axis in that rule is where code executes, never where editing happens, so a macOS or Windows contributor is unaffected for editing and runs these through a container or a remote Linux host. Making these genuinely portable is possible and is a real change with a real cost, and it would be justified by a contributor who needs to run a deploy natively on macOS, not by the syntax of one line. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (1)
checks/check-live-urls.sh:100
- The preflight request suppresses curl errors (
-s) and doesn’t check curl’s exit status. If DNS/TLS/connect fails,preflight_headersbecomes empty and the script reports a misleading HTTP status failure (and may suggest auth/symlink causes). Make the preflight curl run with-sSand fail explicitly on curl errors so the output reflects transport problems correctly.
preflight_headers=$(curl -s -o /dev/null -D- -w '%{http_code}' --max-time 30 "${AUTH[@]}" "$BASE/")
preflight="${preflight_headers##*$'\n'}"
header_of() { printf '%s' "$preflight_headers" | grep -i "^$1:" | tr -d '\r' | sed 's/^[^:]*: *//'; }
A name that does not resolve produced a status code, which the preflight then explained as a bad credential or a broken symlink. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Valid. Fixed in aec533c. A transport failure did surface as a status code, and the diagnosis that followed was then confidently wrong: it offered a bad credential or a dangling symlink as causes for a host that never answered. Both branches exist to explain a reply, so neither applies when there was none. The preflight now separates the two, with curl's own message rather than a paraphrase: This is the third finding in this review of the same shape, and they are worth naming together: a check that reports the wrong cause is more expensive than one that reports nothing, because it sends someone to the wrong layer with confidence. The dangling-symlink message and the dead-watcher message were both added for that reason, and this one had the defect they were built to avoid. Verified against all three environments afterwards, including the live staging site behind the auth gate: |
Why
The repo could address a single deploy root, which was enough for one mirror and is not enough for a staging site and a production site.
make-release.shalso sourced its environment file withset -a, which overwrites aDEPLOY_ROOTthe caller exported, so selecting an environment by exporting that variable silently deployed to the original root. On a host serving two sites that failure is not an error, it publishes to the other site.What this adds
Per-environment configuration.
ENV_FILEselects the file, the first argument still overrides the root, and a named file that does not exist is a hard failure rather than a fall-through to the ambient environment.A deploy that proves itself. A release reaches a host by rsync with no restart, so nothing outside the verifier observes whether the rules answering are the rules shipped. Content follows the
currentsymlink per request and is live immediately, while rules wait on an in-process config reload. The bundle now stamps its own version asX-Blog-Release, andcheck-live-urls.shcompares it againstEXPECT_RELEASEbefore requesting any of the 1,245 URLs. It waits rather than sampling once, because the reload is asynchronous.Environment identification.
X-Blog-EnvandX-Robots-Tagcome from container variables, since every environment serves one bundle on one port and only the container distinguishes them.X-Robots-Tagdefaults to the value that is harmless on production: an unset staging container is still behind its auth gate, where production inheritingnoindexwould deindex the site silently.Auth-gate support. Staging keeps authentication on, so the checker presents a resource access token through a mode-600 curl config file rather than
-Harguments, which keeps the credential out ofpsacross 1,245 requests and is the only form that survives theexport -fthe parallel checks run under.The deploy workflows.
deploy-site-task.ymlis the repo-owned leaf that takes anenvironmentinput and names no host;deploy-site.ymlvalidates through the same gate the pull request and a release use, asserts production deploys only frommain, and calls the leaf.Verification
Both local mirrors deploy with no restart and pass
PASS - 1245 URLs honored. The transport was proven against the real remote host through its confined key: dry run, 601 MB upload, the site still serving the previous release between upload and flip, then the flip.Every new gate was demonstrated failing before being trusted, per the rule in
TODO.md:Notes
caddy run --watchstops watching permanently after one failed config load and logs nothing further, so anything that breakscurrenteven briefly, including a test, ends that container's ability to pick up releases until it is restarted. The failure reports as healthy throughout, which is why the verifier names it as a likely cause.trusted_proxiesexcludes the bridge gateway. Trusting the subnet trusts the host, verified by forging a client address from it.🤖 Generated with Claude Code