Queue-North-Website/scripts/release.sh

414 lines
20 KiB
Bash
Raw Normal View History

feat(release): adapt PrivacyLLC-Web's release.sh, and add the deploy half it deliberately omits scripts/release.sh — publish. Bump, guards, build, verify the image's own version label, push :vX.Y.Z and move :dev, commit last, tag. Adapted, not copied. The arguments are PrivacyLLC's and were paid for there; the mechanism is this project's, because almost none of it transferred. Three differences, each a fact about this repository rather than a preference: - It gates on verify.sh, not a test suite, because there is not one. The original refuses to release on a half-run 1,600-test run. This one says out loud that a build, a secret scan and a doc-header check are not tests and that nothing in the gate touched a route, a form or an API response. - It moves :dev as well as publishing :vX.Y.Z, because Portainer stack 58 follows :dev. That makes :dev a pointer and never evidence. - It checks the public origin in three files, not one. The original passes its origin in as a build arg; here https://queuenorth.com is written out in src/lib/seo.js, src/components/SEO.jsx and scripts/prerender.js and is baked into every canonical URL, og:url, sitemap.xml and robots.txt. The guard asks whether the three still agree. Why this was needed: publishing was `npm run docker:push` with the bump as a separate thing to remember, and it was not remembered. package.json said 0.8.3 while four commits announced "batch 0.9.0" through "0.9.3", and NO image was ever published for any of them — the registry's newest tag is v0.8.3. No prune. The original has one; this project has published thirteen tags in its life, deleting a published image is irreversible, and the one that matters is whichever the container was created from — exactly what a newest-N rule gets wrong. scripts/deploy.sh — deploy, and do not build. The template's deploy.py builds, pushes AND deploys; adopting it beside release.sh would mean two commands that both build, a second image for the same code, and two answers to "what is running". This does only the missing half: point stack 58 at an already published tag, having taken a verified backup first. Its most important guard is not the obvious one. Portainer treats a stack PUT as the whole desired state, so sending it without the stack's Env array would strip twelve variables — the reCAPTCHA secret and the Zoho form tokens among them — and the container would come back HEALTHY while quietly capturing no leads. It reads them, counts them, sends them back, and refuses outright if none. Guards proven to refuse, not assumed: already-published tag, http origin, trailing-slash origin, the three origin constants drifted, dirty tree, bad flag, unpublished deploy tag, missing Portainer key, wrong stack id. One real bug found and fixed while testing: the image-line rewrite used `python3 -` with a heredoc while also piping the stack file to stdin, so python tried to execute the YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:49:03 -05:00
#!/usr/bin/env bash
#
# Build the image, publish it, and leave every version string in the repository
# agreeing with the tag that was published.
#
# npm run release # patch: 0.9.3 -> 0.9.4
# npm run release -- minor # 0.9.3 -> 0.10.0
# npm run release -- 1.0.0 # exact
# npm run release -- --dry-run # say what it would do, change nothing
#
# ===========================================================================
# ADAPTED FROM PrivacyLLC-Web's scripts/release.sh
# ===========================================================================
#
# The arguments below are that script's and are kept because they were paid for
# there. The implementation is this project's, because almost none of the
# mechanism transferred: that project gates on `tsc` and a 1,600-test vitest
# suite, pins an immutable version tag in its stack, and passes its public
# origin in as a build arg. This one has no tests at all, follows a floating
# `:dev` tag, and hard-codes its origin in three source files.
#
# Copying it verbatim would have produced a script that fails on its first line
# and lies on several others.
#
# ## Why this exists
#
# Publishing here was `npm run docker:push` — build, tag `:dev`, push — with the
# version bump as a separate thing to remember. It was not remembered:
# `package.json` said 0.8.3 while the four most recent commits announced
# "batch 0.9.0" through "batch 0.9.3", and **no image was ever published for any
# of them**. The registry's newest tag is v0.8.3. Four batches of work went out
# under a version number that names none of them.
#
# So the bump is not a step beside the release. It is what this command does.
#
# ## package.json is the source of truth
#
# The tag is always `v` + the version in package.json. There is no --tag flag,
# because a flag would be a second source of truth and this script exists
# because there were several.
#
# Arithmetic is `npm version`, which enforces semver. That is load-bearing: this
# registry already holds `dev-v0.7.3` and `latest` alongside real versions, and
# a hand-typed tag is how that happens.
#
# ## The ordering is the safety property
#
# Bump, guard, build, verify, push, and commit LAST. Never pass through a state
# you cannot explain.
#
# If the build or push fails, the edits sit in the working tree — visible,
# uncommitted, one `git checkout` from gone. If the commit came first, a failure
# would leave `dev` carrying a commit announcing a release that was never
# published, and `.githooks/post-commit` would already have pushed it.
#
feat(deploy): production always runs a numbered version Policy set by _null. The stack pins :vX.Y.Z — never :dev, never :latest. The reason is what a pointer cannot do. A stack file naming :dev says "dev" today and will still say "dev" after the image underneath it changes, so the file records a preference rather than a fact and "what is running?" has no answer that survives the next push. Rollback is worse: there is nothing to roll back TO, because a pointer only ever names now. deploy.sh: with no --tag it now resolves the newest published NUMBERED version from the registry and says which it picked, instead of defaulting to :dev. A floating tag is refused outright, with --allow-floating as the loud escape hatch for the one case that is not a mistake — reproducing a fault on whatever a pointer currently is. release.sh: no longer moves :dev. It published both tags until now because the stack followed the pointer; under this policy nothing deploys :dev, so moving it would only publish something that misrepresents what is running. scripts/docker-push.sh deleted, with its npm script. Its entire job was building and pushing an unnumbered :dev with no version bump and no guards, which is now the exact thing the policy exists to prevent. Keeping a command that quietly violates a policy is how the policy stops being true. :dev and :latest stay in the registry, frozen. Not deleted: the running container was created from :dev, and removing the tag an existing deployment names is how a recreate fails to pull. Stack 58 still pins :dev as this lands. Correcting that is the first deploy made under the policy, and it is why the default resolves a version rather than reusing whatever the stack already names. Guards proven: default resolves v0.8.3; --tag dev refused; --tag latest refused; --allow-floating warns and proceeds; unpublished tag still refused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:54:42 -05:00
# ## One tag, and it is a record
feat(release): adapt PrivacyLLC-Web's release.sh, and add the deploy half it deliberately omits scripts/release.sh — publish. Bump, guards, build, verify the image's own version label, push :vX.Y.Z and move :dev, commit last, tag. Adapted, not copied. The arguments are PrivacyLLC's and were paid for there; the mechanism is this project's, because almost none of it transferred. Three differences, each a fact about this repository rather than a preference: - It gates on verify.sh, not a test suite, because there is not one. The original refuses to release on a half-run 1,600-test run. This one says out loud that a build, a secret scan and a doc-header check are not tests and that nothing in the gate touched a route, a form or an API response. - It moves :dev as well as publishing :vX.Y.Z, because Portainer stack 58 follows :dev. That makes :dev a pointer and never evidence. - It checks the public origin in three files, not one. The original passes its origin in as a build arg; here https://queuenorth.com is written out in src/lib/seo.js, src/components/SEO.jsx and scripts/prerender.js and is baked into every canonical URL, og:url, sitemap.xml and robots.txt. The guard asks whether the three still agree. Why this was needed: publishing was `npm run docker:push` with the bump as a separate thing to remember, and it was not remembered. package.json said 0.8.3 while four commits announced "batch 0.9.0" through "0.9.3", and NO image was ever published for any of them — the registry's newest tag is v0.8.3. No prune. The original has one; this project has published thirteen tags in its life, deleting a published image is irreversible, and the one that matters is whichever the container was created from — exactly what a newest-N rule gets wrong. scripts/deploy.sh — deploy, and do not build. The template's deploy.py builds, pushes AND deploys; adopting it beside release.sh would mean two commands that both build, a second image for the same code, and two answers to "what is running". This does only the missing half: point stack 58 at an already published tag, having taken a verified backup first. Its most important guard is not the obvious one. Portainer treats a stack PUT as the whole desired state, so sending it without the stack's Env array would strip twelve variables — the reCAPTCHA secret and the Zoho form tokens among them — and the container would come back HEALTHY while quietly capturing no leads. It reads them, counts them, sends them back, and refuses outright if none. Guards proven to refuse, not assumed: already-published tag, http origin, trailing-slash origin, the three origin constants drifted, dirty tree, bad flag, unpublished deploy tag, missing Portainer key, wrong stack id. One real bug found and fixed while testing: the image-line rewrite used `python3 -` with a heredoc while also piping the stack file to stdin, so python tried to execute the YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:49:03 -05:00
#
feat(deploy): production always runs a numbered version Policy set by _null. The stack pins :vX.Y.Z — never :dev, never :latest. The reason is what a pointer cannot do. A stack file naming :dev says "dev" today and will still say "dev" after the image underneath it changes, so the file records a preference rather than a fact and "what is running?" has no answer that survives the next push. Rollback is worse: there is nothing to roll back TO, because a pointer only ever names now. deploy.sh: with no --tag it now resolves the newest published NUMBERED version from the registry and says which it picked, instead of defaulting to :dev. A floating tag is refused outright, with --allow-floating as the loud escape hatch for the one case that is not a mistake — reproducing a fault on whatever a pointer currently is. release.sh: no longer moves :dev. It published both tags until now because the stack followed the pointer; under this policy nothing deploys :dev, so moving it would only publish something that misrepresents what is running. scripts/docker-push.sh deleted, with its npm script. Its entire job was building and pushing an unnumbered :dev with no version bump and no guards, which is now the exact thing the policy exists to prevent. Keeping a command that quietly violates a policy is how the policy stops being true. :dev and :latest stay in the registry, frozen. Not deleted: the running container was created from :dev, and removing the tag an existing deployment names is how a recreate fails to pull. Stack 58 still pins :dev as this lands. Correcting that is the first deploy made under the policy, and it is why the default resolves a version rather than reusing whatever the stack already names. Guards proven: default resolves v0.8.3; --tag dev refused; --tag latest refused; --allow-floating warns and proceeds; unpublished tag still refused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:54:42 -05:00
# `:vX.Y.Z`, immutable. Publishing over one is refused, because a running stack
# would silently pull different code on its next recreate while the tag said
# nothing had changed.
feat(release): adapt PrivacyLLC-Web's release.sh, and add the deploy half it deliberately omits scripts/release.sh — publish. Bump, guards, build, verify the image's own version label, push :vX.Y.Z and move :dev, commit last, tag. Adapted, not copied. The arguments are PrivacyLLC's and were paid for there; the mechanism is this project's, because almost none of it transferred. Three differences, each a fact about this repository rather than a preference: - It gates on verify.sh, not a test suite, because there is not one. The original refuses to release on a half-run 1,600-test run. This one says out loud that a build, a secret scan and a doc-header check are not tests and that nothing in the gate touched a route, a form or an API response. - It moves :dev as well as publishing :vX.Y.Z, because Portainer stack 58 follows :dev. That makes :dev a pointer and never evidence. - It checks the public origin in three files, not one. The original passes its origin in as a build arg; here https://queuenorth.com is written out in src/lib/seo.js, src/components/SEO.jsx and scripts/prerender.js and is baked into every canonical URL, og:url, sitemap.xml and robots.txt. The guard asks whether the three still agree. Why this was needed: publishing was `npm run docker:push` with the bump as a separate thing to remember, and it was not remembered. package.json said 0.8.3 while four commits announced "batch 0.9.0" through "0.9.3", and NO image was ever published for any of them — the registry's newest tag is v0.8.3. No prune. The original has one; this project has published thirteen tags in its life, deleting a published image is irreversible, and the one that matters is whichever the container was created from — exactly what a newest-N rule gets wrong. scripts/deploy.sh — deploy, and do not build. The template's deploy.py builds, pushes AND deploys; adopting it beside release.sh would mean two commands that both build, a second image for the same code, and two answers to "what is running". This does only the missing half: point stack 58 at an already published tag, having taken a verified backup first. Its most important guard is not the obvious one. Portainer treats a stack PUT as the whole desired state, so sending it without the stack's Env array would strip twelve variables — the reCAPTCHA secret and the Zoho form tokens among them — and the container would come back HEALTHY while quietly capturing no leads. It reads them, counts them, sends them back, and refuses outright if none. Guards proven to refuse, not assumed: already-published tag, http origin, trailing-slash origin, the three origin constants drifted, dirty tree, bad flag, unpublished deploy tag, missing Portainer key, wrong stack id. One real bug found and fixed while testing: the image-line rewrite used `python3 -` with a heredoc while also piping the stack file to stdin, so python tried to execute the YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:49:03 -05:00
#
feat(deploy): production always runs a numbered version Policy set by _null. The stack pins :vX.Y.Z — never :dev, never :latest. The reason is what a pointer cannot do. A stack file naming :dev says "dev" today and will still say "dev" after the image underneath it changes, so the file records a preference rather than a fact and "what is running?" has no answer that survives the next push. Rollback is worse: there is nothing to roll back TO, because a pointer only ever names now. deploy.sh: with no --tag it now resolves the newest published NUMBERED version from the registry and says which it picked, instead of defaulting to :dev. A floating tag is refused outright, with --allow-floating as the loud escape hatch for the one case that is not a mistake — reproducing a fault on whatever a pointer currently is. release.sh: no longer moves :dev. It published both tags until now because the stack followed the pointer; under this policy nothing deploys :dev, so moving it would only publish something that misrepresents what is running. scripts/docker-push.sh deleted, with its npm script. Its entire job was building and pushing an unnumbered :dev with no version bump and no guards, which is now the exact thing the policy exists to prevent. Keeping a command that quietly violates a policy is how the policy stops being true. :dev and :latest stay in the registry, frozen. Not deleted: the running container was created from :dev, and removing the tag an existing deployment names is how a recreate fails to pull. Stack 58 still pins :dev as this lands. Correcting that is the first deploy made under the policy, and it is why the default resolves a version rather than reusing whatever the stack already names. Guards proven: default resolves v0.8.3; --tag dev refused; --tag latest refused; --allow-floating warns and proceeds; unpublished tag still refused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:54:42 -05:00
# **It does not move `:dev`, deliberately.** An earlier version of this script
# did, because stack 58 followed that pointer. The policy changed on 2026-08-18:
# production always runs a numbered version, so nothing deploys `:dev` any more
# and moving it would publish a pointer that misrepresents what is running to
# anybody who reads it. `scripts/deploy.sh` refuses a floating tag outright.
#
# The `:dev` and `:latest` tags already in this registry are frozen where they
# are rather than deleted — the running container was created from `:dev`, and
# deleting the tag an existing deployment names is how a recreate fails to pull.
feat(release): adapt PrivacyLLC-Web's release.sh, and add the deploy half it deliberately omits scripts/release.sh — publish. Bump, guards, build, verify the image's own version label, push :vX.Y.Z and move :dev, commit last, tag. Adapted, not copied. The arguments are PrivacyLLC's and were paid for there; the mechanism is this project's, because almost none of it transferred. Three differences, each a fact about this repository rather than a preference: - It gates on verify.sh, not a test suite, because there is not one. The original refuses to release on a half-run 1,600-test run. This one says out loud that a build, a secret scan and a doc-header check are not tests and that nothing in the gate touched a route, a form or an API response. - It moves :dev as well as publishing :vX.Y.Z, because Portainer stack 58 follows :dev. That makes :dev a pointer and never evidence. - It checks the public origin in three files, not one. The original passes its origin in as a build arg; here https://queuenorth.com is written out in src/lib/seo.js, src/components/SEO.jsx and scripts/prerender.js and is baked into every canonical URL, og:url, sitemap.xml and robots.txt. The guard asks whether the three still agree. Why this was needed: publishing was `npm run docker:push` with the bump as a separate thing to remember, and it was not remembered. package.json said 0.8.3 while four commits announced "batch 0.9.0" through "0.9.3", and NO image was ever published for any of them — the registry's newest tag is v0.8.3. No prune. The original has one; this project has published thirteen tags in its life, deleting a published image is irreversible, and the one that matters is whichever the container was created from — exactly what a newest-N rule gets wrong. scripts/deploy.sh — deploy, and do not build. The template's deploy.py builds, pushes AND deploys; adopting it beside release.sh would mean two commands that both build, a second image for the same code, and two answers to "what is running". This does only the missing half: point stack 58 at an already published tag, having taken a verified backup first. Its most important guard is not the obvious one. Portainer treats a stack PUT as the whole desired state, so sending it without the stack's Env array would strip twelve variables — the reCAPTCHA secret and the Zoho form tokens among them — and the container would come back HEALTHY while quietly capturing no leads. It reads them, counts them, sends them back, and refuses outright if none. Guards proven to refuse, not assumed: already-published tag, http origin, trailing-slash origin, the three origin constants drifted, dirty tree, bad flag, unpublished deploy tag, missing Portainer key, wrong stack id. One real bug found and fixed while testing: the image-line rewrite used `python3 -` with a heredoc while also piping the stack file to stdin, so python tried to execute the YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:49:03 -05:00
#
# ## What it deliberately does not do
#
# **It does not deploy.** Publishing an image and running it are separate
# decisions. `scripts/deploy.py` makes the second one, and this script prints
# the command rather than running it.
#
# **It does not prune the registry.** PrivacyLLC's version does, because it
# releases often enough for that to matter. This project has published thirteen
# tags in its whole life, deleting a published image is irreversible, and the
# one that matters is whichever the running container was created from — which
# is exactly the thing a newest-N rule gets wrong. Nothing here deletes anything.
#
# Exit codes: 0 released. 1 something failed and the message says what state it
# left behind. 2 nothing was attempted — bad usage, or a guard that could not run.
set -uo pipefail
cd "$(git rev-parse --show-toplevel)" || exit 1
IMAGE="${RELEASE_IMAGE:-dream.scheller.ltd/null/queue-north-website}"
SITE_URL="${RELEASE_SITE_URL:-https://queuenorth.com}"
REGISTRY_ENV="${RELEASE_REGISTRY_ENV:-$HOME/.openclaw/docker-registry.env}"
fix(deploy): stop two ways this tooling would have broken production Both found by checking before running, not by running. 1. release.sh would have shipped a site whose contact form cannot be submitted. .dockerignore excludes .env from the build context, and the script passed --build-arg VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-} without ever loading .env — and the variable is unset in every shell. The `:-` made empty silently acceptable. An empty key makes RecaptchaPlaceholder render "Security verification is not configured." and produce no token, and the server has RECAPTCHA_ENABLED=true, so every submission is rejected. Lead capture stops. Now: loads VITE_* from .env, refuses an empty key outright, and greps the BUILT image's bundle for it before pushing — ask the artifact, do not trust the wiring, the same move already used for the version label. 2. deploy.sh's default would have rolled production back two months. The newest published NUMBERED tag is v0.8.3, built 2026-05-28; the running :dev image was built 2026-08-01. v0.8.3 has no privacy policy and no prerendered routes at all. Now: the target's build date is read from the registry without pulling it, compared against what is running, and refused if older unless --rollback is passed. Also in deploy.sh: - --fix-cors removes the trailing slash from CORS_ORIGIN (#212), and --watchtower-off adds com.centurylinklabs.watchtower.enable=false. Both ride in the same PUT so production restarts once, not three times. - The stack file is now read to a temp file instead of $( ), which was stripping its trailing newline — a change beyond the lines the script claims to touch. - The env-line count is asserted before sending. Verified with `docker compose config` why that matters: without the Env array the ${VAR:-false} defaults resolve reCAPTCHA and Zoho WebToLead to false, so the container would come back healthy and quietly stop capturing leads. - Post-deploy it checks health, both origins, AND that the live bundle carries the site key the stack declares — the failure no health check can see. - Any of those failing triggers an automatic rollback to the original stack bytes, once. If the rollback also fails it stops and says so rather than retrying, because a script retrying an outage is how a short one becomes long. The resulting stack file was validated with `docker compose config`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:33:20 -05:00
# Public values Vite freezes into the bundle at build time. See the guard below.
BUILD_ENV_FILE="${RELEASE_BUILD_ENV:-.env}"
feat(release): adapt PrivacyLLC-Web's release.sh, and add the deploy half it deliberately omits scripts/release.sh — publish. Bump, guards, build, verify the image's own version label, push :vX.Y.Z and move :dev, commit last, tag. Adapted, not copied. The arguments are PrivacyLLC's and were paid for there; the mechanism is this project's, because almost none of it transferred. Three differences, each a fact about this repository rather than a preference: - It gates on verify.sh, not a test suite, because there is not one. The original refuses to release on a half-run 1,600-test run. This one says out loud that a build, a secret scan and a doc-header check are not tests and that nothing in the gate touched a route, a form or an API response. - It moves :dev as well as publishing :vX.Y.Z, because Portainer stack 58 follows :dev. That makes :dev a pointer and never evidence. - It checks the public origin in three files, not one. The original passes its origin in as a build arg; here https://queuenorth.com is written out in src/lib/seo.js, src/components/SEO.jsx and scripts/prerender.js and is baked into every canonical URL, og:url, sitemap.xml and robots.txt. The guard asks whether the three still agree. Why this was needed: publishing was `npm run docker:push` with the bump as a separate thing to remember, and it was not remembered. package.json said 0.8.3 while four commits announced "batch 0.9.0" through "0.9.3", and NO image was ever published for any of them — the registry's newest tag is v0.8.3. No prune. The original has one; this project has published thirteen tags in its life, deleting a published image is irreversible, and the one that matters is whichever the container was created from — exactly what a newest-N rule gets wrong. scripts/deploy.sh — deploy, and do not build. The template's deploy.py builds, pushes AND deploys; adopting it beside release.sh would mean two commands that both build, a second image for the same code, and two answers to "what is running". This does only the missing half: point stack 58 at an already published tag, having taken a verified backup first. Its most important guard is not the obvious one. Portainer treats a stack PUT as the whole desired state, so sending it without the stack's Env array would strip twelve variables — the reCAPTCHA secret and the Zoho form tokens among them — and the container would come back HEALTHY while quietly capturing no leads. It reads them, counts them, sends them back, and refuses outright if none. Guards proven to refuse, not assumed: already-published tag, http origin, trailing-slash origin, the three origin constants drifted, dirty tree, bad flag, unpublished deploy tag, missing Portainer key, wrong stack id. One real bug found and fixed while testing: the image-line rewrite used `python3 -` with a heredoc while also piping the stack file to stdin, so python tried to execute the YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:49:03 -05:00
# Files carrying a version string. The Dockerfile is here because its
# `ARG APP_VERSION=` becomes the image's org.opencontainers.image.version label,
# which `status.sh --deployed-version` reads — left unbumped, every future
# release would report the version of the one before it.
FILES=(package.json package-lock.json Dockerfile)
# Every place the public origin is hard-coded. See the guard below.
ORIGIN_FILES=(src/lib/seo.js src/components/SEO.jsx scripts/prerender.js)
say() { printf '\033[1mrelease:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1mrelease:\033[0m %s\n' "$*" >&2; exit 1; }
stop() { printf '\033[1mrelease:\033[0m %s\n' "$*" >&2; exit 2; }
# ---------------------------------------------------------------------------
# The public origin, checked before a build freezes it in.
#
# It is baked into the artifact and cannot be corrected without another build:
# canonical URLs, og:url, sitemap.xml and robots.txt are all prerendered from
# it. A wrong one is silent, ships, and is found by somebody wondering why
# Google indexed a staging host.
#
# This project differs from the one this script came from in a way that matters:
# there the origin arrives as a build arg, so there is one copy. Here it is
# written out in three source files, so the guard is not "is the value sane" but
# "do all three still agree" — three copies of a constant is a drift waiting to
# happen, and the drift would be invisible until somebody read the page source.
# ---------------------------------------------------------------------------
case "$SITE_URL" in
https://*)
printf '%s' "$SITE_URL" | grep -Eq '^https://[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,}$' \
|| die "RELEASE_SITE_URL is not a bare https origin: ${SITE_URL}
It is baked into every canonical URL, og:url, the sitemap and robots.txt,
and cannot be changed without another build. Expected something like
https://queuenorth.com with no path and no trailing slash." ;;
*)
die "RELEASE_SITE_URL must be an https origin, got: ${SITE_URL:-<empty>}
http would publish canonical URLs this deployment does not serve." ;;
esac
for f in "${ORIGIN_FILES[@]}"; do
[ -f "$f" ] || die "$f is missing, so the public origin could not be checked.
That file is one of the places ${SITE_URL} is written down; if it moved,
update ORIGIN_FILES in this script before releasing."
grep -Fq "$SITE_URL" "$f" \
|| die "$f does not contain ${SITE_URL}.
The public origin is hard-coded in ${#ORIGIN_FILES[@]} files and they have drifted.
Whatever this file says instead is what will be baked into the canonical
URLs, and it is not what you asked for. Fix it before releasing."
done
fix(deploy): stop two ways this tooling would have broken production Both found by checking before running, not by running. 1. release.sh would have shipped a site whose contact form cannot be submitted. .dockerignore excludes .env from the build context, and the script passed --build-arg VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-} without ever loading .env — and the variable is unset in every shell. The `:-` made empty silently acceptable. An empty key makes RecaptchaPlaceholder render "Security verification is not configured." and produce no token, and the server has RECAPTCHA_ENABLED=true, so every submission is rejected. Lead capture stops. Now: loads VITE_* from .env, refuses an empty key outright, and greps the BUILT image's bundle for it before pushing — ask the artifact, do not trust the wiring, the same move already used for the version label. 2. deploy.sh's default would have rolled production back two months. The newest published NUMBERED tag is v0.8.3, built 2026-05-28; the running :dev image was built 2026-08-01. v0.8.3 has no privacy policy and no prerendered routes at all. Now: the target's build date is read from the registry without pulling it, compared against what is running, and refused if older unless --rollback is passed. Also in deploy.sh: - --fix-cors removes the trailing slash from CORS_ORIGIN (#212), and --watchtower-off adds com.centurylinklabs.watchtower.enable=false. Both ride in the same PUT so production restarts once, not three times. - The stack file is now read to a temp file instead of $( ), which was stripping its trailing newline — a change beyond the lines the script claims to touch. - The env-line count is asserted before sending. Verified with `docker compose config` why that matters: without the Env array the ${VAR:-false} defaults resolve reCAPTCHA and Zoho WebToLead to false, so the container would come back healthy and quietly stop capturing leads. - Post-deploy it checks health, both origins, AND that the live bundle carries the site key the stack declares — the failure no health check can see. - Any of those failing triggers an automatic rollback to the original stack bytes, once. If the rollback also fails it stops and says so rather than retrying, because a script retrying an outage is how a short one becomes long. The resulting stack file was validated with `docker compose config`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:33:20 -05:00
# ---------------------------------------------------------------------------
# The reCAPTCHA site key, which is frozen into the bundle exactly like the
# origin above and is the one that stops the product working.
#
# `.dockerignore` excludes .env from the build context, so Vite inside the
# container cannot read it; the value has to arrive as a build arg. An earlier
# version of this script passed `${VITE_RECAPTCHA_SITE_KEY:-}` and never loaded
# .env, so on a shell where it was unset — which is every shell — it would have
# built an image with an EMPTY key.
#
# That is not a degraded build. src/components/RecaptchaPlaceholder.jsx renders
# "Security verification is not configured." in place of the widget and never
# produces a token, and the server has RECAPTCHA_ENABLED=true, so every contact
# submission is rejected with "Security verification is required". The form
# visibly breaks and lead capture stops — the entire purpose of the site — and
# nothing about the build says so.
#
# So: load it, and refuse when it is empty. No `:-` fallback, because empty is
# the failure rather than a default.
# ---------------------------------------------------------------------------
if [ -r "$BUILD_ENV_FILE" ]; then
# Only the VITE_ keys, and only when not already set in the environment.
# Sourcing the whole file would drag the server's secrets into a build that
# has no use for them.
while IFS='=' read -r k v; do
case "$k" in
VITE_*) [ -n "${!k:-}" ] || export "$k=$v" ;;
esac
done < <(grep -E '^VITE_[A-Z0-9_]+=' "$BUILD_ENV_FILE" 2>/dev/null)
fi
[ -n "${VITE_RECAPTCHA_SITE_KEY:-}" ] || die "VITE_RECAPTCHA_SITE_KEY is empty, and it is baked into the bundle.
An empty one does not degrade the form, it breaks it: the widget is
replaced by \"Security verification is not configured.\", no token is
produced, and the server rejects every submission because
RECAPTCHA_ENABLED=true. Lead capture stops.
It lives in ${BUILD_ENV_FILE}, and in stack 58's environment.
Nothing was built."
# The site key is public by design — it ships to every visitor — so echoing a
# prefix is not a leak, and seeing it is how you catch the wrong one.
say "recaptcha site key ${VITE_RECAPTCHA_SITE_KEY:0:14}… (public, baked into the bundle)"
feat(release): adapt PrivacyLLC-Web's release.sh, and add the deploy half it deliberately omits scripts/release.sh — publish. Bump, guards, build, verify the image's own version label, push :vX.Y.Z and move :dev, commit last, tag. Adapted, not copied. The arguments are PrivacyLLC's and were paid for there; the mechanism is this project's, because almost none of it transferred. Three differences, each a fact about this repository rather than a preference: - It gates on verify.sh, not a test suite, because there is not one. The original refuses to release on a half-run 1,600-test run. This one says out loud that a build, a secret scan and a doc-header check are not tests and that nothing in the gate touched a route, a form or an API response. - It moves :dev as well as publishing :vX.Y.Z, because Portainer stack 58 follows :dev. That makes :dev a pointer and never evidence. - It checks the public origin in three files, not one. The original passes its origin in as a build arg; here https://queuenorth.com is written out in src/lib/seo.js, src/components/SEO.jsx and scripts/prerender.js and is baked into every canonical URL, og:url, sitemap.xml and robots.txt. The guard asks whether the three still agree. Why this was needed: publishing was `npm run docker:push` with the bump as a separate thing to remember, and it was not remembered. package.json said 0.8.3 while four commits announced "batch 0.9.0" through "0.9.3", and NO image was ever published for any of them — the registry's newest tag is v0.8.3. No prune. The original has one; this project has published thirteen tags in its life, deleting a published image is irreversible, and the one that matters is whichever the container was created from — exactly what a newest-N rule gets wrong. scripts/deploy.sh — deploy, and do not build. The template's deploy.py builds, pushes AND deploys; adopting it beside release.sh would mean two commands that both build, a second image for the same code, and two answers to "what is running". This does only the missing half: point stack 58 at an already published tag, having taken a verified backup first. Its most important guard is not the obvious one. Portainer treats a stack PUT as the whole desired state, so sending it without the stack's Env array would strip twelve variables — the reCAPTCHA secret and the Zoho form tokens among them — and the container would come back HEALTHY while quietly capturing no leads. It reads them, counts them, sends them back, and refuses outright if none. Guards proven to refuse, not assumed: already-published tag, http origin, trailing-slash origin, the three origin constants drifted, dirty tree, bad flag, unpublished deploy tag, missing Portainer key, wrong stack id. One real bug found and fixed while testing: the image-line rewrite used `python3 -` with a heredoc while also piping the stack file to stdin, so python tried to execute the YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:49:03 -05:00
BUMP="patch"
DRY_RUN=""
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN="yes" ;;
-h|--help) say "usage: npm run release -- [patch|minor|major|<version>] [--dry-run]"; exit 0 ;;
-*) stop "unknown flag $1. Usage: npm run release -- [patch|minor|major|<version>] [--dry-run]" ;;
*) BUMP="$1" ;;
esac
shift
done
command -v docker >/dev/null 2>&1 || stop "docker is not on PATH. Nothing was attempted."
command -v npm >/dev/null 2>&1 || stop "npm is not on PATH. Nothing was attempted."
# ---------------------------------------------------------------------------
# A clean tree, because the commit at the end stages by explicit path and
# anything else sitting there would be released without being looked at.
# ---------------------------------------------------------------------------
if ! git diff --quiet || ! git diff --cached --quiet; then
say "the working tree has uncommitted changes:"
git status --short >&2
die "commit or stash them first. This script commits ${FILES[*]} at the end and
cannot tell your work in progress from a release."
fi
current=$(node -p "require('./package.json').version" 2>/dev/null) \
|| stop "could not read the version from package.json."
# Compute the next version without writing it, so the already-published check
# can refuse before anything is touched.
work=$(mktemp -d) || stop "could not create a scratch directory."
trap 'rm -rf "$work"' EXIT
cp package.json "$work/" 2>/dev/null || stop "could not copy package.json to scratch."
next=$(cd "$work" && npm version --no-git-tag-version "$BUMP" 2>/dev/null | tr -d 'v\n')
[ -n "$next" ] || die "npm rejected '$BUMP' as a version bump."
TAG="v${next}"
feat(deploy): production always runs a numbered version Policy set by _null. The stack pins :vX.Y.Z — never :dev, never :latest. The reason is what a pointer cannot do. A stack file naming :dev says "dev" today and will still say "dev" after the image underneath it changes, so the file records a preference rather than a fact and "what is running?" has no answer that survives the next push. Rollback is worse: there is nothing to roll back TO, because a pointer only ever names now. deploy.sh: with no --tag it now resolves the newest published NUMBERED version from the registry and says which it picked, instead of defaulting to :dev. A floating tag is refused outright, with --allow-floating as the loud escape hatch for the one case that is not a mistake — reproducing a fault on whatever a pointer currently is. release.sh: no longer moves :dev. It published both tags until now because the stack followed the pointer; under this policy nothing deploys :dev, so moving it would only publish something that misrepresents what is running. scripts/docker-push.sh deleted, with its npm script. Its entire job was building and pushing an unnumbered :dev with no version bump and no guards, which is now the exact thing the policy exists to prevent. Keeping a command that quietly violates a policy is how the policy stops being true. :dev and :latest stay in the registry, frozen. Not deleted: the running container was created from :dev, and removing the tag an existing deployment names is how a recreate fails to pull. Stack 58 still pins :dev as this lands. Correcting that is the first deploy made under the policy, and it is why the default resolves a version rather than reusing whatever the stack already names. Guards proven: default resolves v0.8.3; --tag dev refused; --tag latest refused; --allow-floating warns and proceeds; unpublished tag still refused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:54:42 -05:00
say "$current -> $next (publishing ${IMAGE}:${TAG})"
feat(release): adapt PrivacyLLC-Web's release.sh, and add the deploy half it deliberately omits scripts/release.sh — publish. Bump, guards, build, verify the image's own version label, push :vX.Y.Z and move :dev, commit last, tag. Adapted, not copied. The arguments are PrivacyLLC's and were paid for there; the mechanism is this project's, because almost none of it transferred. Three differences, each a fact about this repository rather than a preference: - It gates on verify.sh, not a test suite, because there is not one. The original refuses to release on a half-run 1,600-test run. This one says out loud that a build, a secret scan and a doc-header check are not tests and that nothing in the gate touched a route, a form or an API response. - It moves :dev as well as publishing :vX.Y.Z, because Portainer stack 58 follows :dev. That makes :dev a pointer and never evidence. - It checks the public origin in three files, not one. The original passes its origin in as a build arg; here https://queuenorth.com is written out in src/lib/seo.js, src/components/SEO.jsx and scripts/prerender.js and is baked into every canonical URL, og:url, sitemap.xml and robots.txt. The guard asks whether the three still agree. Why this was needed: publishing was `npm run docker:push` with the bump as a separate thing to remember, and it was not remembered. package.json said 0.8.3 while four commits announced "batch 0.9.0" through "0.9.3", and NO image was ever published for any of them — the registry's newest tag is v0.8.3. No prune. The original has one; this project has published thirteen tags in its life, deleting a published image is irreversible, and the one that matters is whichever the container was created from — exactly what a newest-N rule gets wrong. scripts/deploy.sh — deploy, and do not build. The template's deploy.py builds, pushes AND deploys; adopting it beside release.sh would mean two commands that both build, a second image for the same code, and two answers to "what is running". This does only the missing half: point stack 58 at an already published tag, having taken a verified backup first. Its most important guard is not the obvious one. Portainer treats a stack PUT as the whole desired state, so sending it without the stack's Env array would strip twelve variables — the reCAPTCHA secret and the Zoho form tokens among them — and the container would come back HEALTHY while quietly capturing no leads. It reads them, counts them, sends them back, and refuses outright if none. Guards proven to refuse, not assumed: already-published tag, http origin, trailing-slash origin, the three origin constants drifted, dirty tree, bad flag, unpublished deploy tag, missing Portainer key, wrong stack id. One real bug found and fixed while testing: the image-line rewrite used `python3 -` with a heredoc while also piping the stack file to stdin, so python tried to execute the YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:49:03 -05:00
# ---------------------------------------------------------------------------
# Refuse to move a tag that is already published.
#
# Overwriting one silently changes what a stack pulls on its next recreate,
# while the tag says nothing changed. Immutable by convention; this makes it
# immutable in practice.
# ---------------------------------------------------------------------------
if [ -r "$REGISTRY_ENV" ]; then
# shellcheck disable=SC1090
set -a; . "$REGISTRY_ENV"; set +a
fi
if [ -n "${FORGEJO_REGISTRY:-}" ] && [ -n "${FORGEJO_REGISTRY_TOKEN:-}" ]; then
repo="${IMAGE#*/}"
tags=$(curl -sS --max-time 20 -u "${FORGEJO_REGISTRY_USER}:${FORGEJO_REGISTRY_TOKEN}" \
"https://${FORGEJO_REGISTRY}/v2/${repo}/tags/list" 2>/dev/null)
if printf '%s' "$tags" | grep -q "\"${TAG}\""; then
die "${TAG} is already published. Pick a higher version — a published tag is not moved."
fi
if [ -z "$tags" ]; then
# Said out loud. "I could not check" and "it is not there" are different
# answers and only one of them is safe to act on.
say "WARNING: could not read the registry tag list. Proceeding WITHOUT the"
say " already-published check."
fi
else
say "WARNING: no registry credentials at $REGISTRY_ENV — cannot check whether"
say " ${TAG} is already published, and the push will likely fail."
fi
if [ -n "$DRY_RUN" ]; then
say "--dry-run: nothing was changed. It would have:"
say " set version ${next} in ${FILES[*]}"
say " bash scripts/verify.sh"
say " docker build --build-arg APP_VERSION=${next} -t ${IMAGE}:${TAG} ."
say " verify the image's org.opencontainers.image.version label reads ${next}"
fix(deploy): stop two ways this tooling would have broken production Both found by checking before running, not by running. 1. release.sh would have shipped a site whose contact form cannot be submitted. .dockerignore excludes .env from the build context, and the script passed --build-arg VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-} without ever loading .env — and the variable is unset in every shell. The `:-` made empty silently acceptable. An empty key makes RecaptchaPlaceholder render "Security verification is not configured." and produce no token, and the server has RECAPTCHA_ENABLED=true, so every submission is rejected. Lead capture stops. Now: loads VITE_* from .env, refuses an empty key outright, and greps the BUILT image's bundle for it before pushing — ask the artifact, do not trust the wiring, the same move already used for the version label. 2. deploy.sh's default would have rolled production back two months. The newest published NUMBERED tag is v0.8.3, built 2026-05-28; the running :dev image was built 2026-08-01. v0.8.3 has no privacy policy and no prerendered routes at all. Now: the target's build date is read from the registry without pulling it, compared against what is running, and refused if older unless --rollback is passed. Also in deploy.sh: - --fix-cors removes the trailing slash from CORS_ORIGIN (#212), and --watchtower-off adds com.centurylinklabs.watchtower.enable=false. Both ride in the same PUT so production restarts once, not three times. - The stack file is now read to a temp file instead of $( ), which was stripping its trailing newline — a change beyond the lines the script claims to touch. - The env-line count is asserted before sending. Verified with `docker compose config` why that matters: without the Env array the ${VAR:-false} defaults resolve reCAPTCHA and Zoho WebToLead to false, so the container would come back healthy and quietly stop capturing leads. - Post-deploy it checks health, both origins, AND that the live bundle carries the site key the stack declares — the failure no health check can see. - Any of those failing triggers an automatic rollback to the original stack bytes, once. If the rollback also fails it stops and says so rather than retrying, because a script retrying an outage is how a short one becomes long. The resulting stack file was validated with `docker compose config`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:33:20 -05:00
say " verify the built bundle contains the reCAPTCHA site key"
feat(deploy): production always runs a numbered version Policy set by _null. The stack pins :vX.Y.Z — never :dev, never :latest. The reason is what a pointer cannot do. A stack file naming :dev says "dev" today and will still say "dev" after the image underneath it changes, so the file records a preference rather than a fact and "what is running?" has no answer that survives the next push. Rollback is worse: there is nothing to roll back TO, because a pointer only ever names now. deploy.sh: with no --tag it now resolves the newest published NUMBERED version from the registry and says which it picked, instead of defaulting to :dev. A floating tag is refused outright, with --allow-floating as the loud escape hatch for the one case that is not a mistake — reproducing a fault on whatever a pointer currently is. release.sh: no longer moves :dev. It published both tags until now because the stack followed the pointer; under this policy nothing deploys :dev, so moving it would only publish something that misrepresents what is running. scripts/docker-push.sh deleted, with its npm script. Its entire job was building and pushing an unnumbered :dev with no version bump and no guards, which is now the exact thing the policy exists to prevent. Keeping a command that quietly violates a policy is how the policy stops being true. :dev and :latest stay in the registry, frozen. Not deleted: the running container was created from :dev, and removing the tag an existing deployment names is how a recreate fails to pull. Stack 58 still pins :dev as this lands. Correcting that is the first deploy made under the policy, and it is why the default resolves a version rather than reusing whatever the stack already names. Guards proven: default resolves v0.8.3; --tag dev refused; --tag latest refused; --allow-floating warns and proceeds; unpublished tag still refused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:54:42 -05:00
say " docker push ${IMAGE}:${TAG}"
feat(release): adapt PrivacyLLC-Web's release.sh, and add the deploy half it deliberately omits scripts/release.sh — publish. Bump, guards, build, verify the image's own version label, push :vX.Y.Z and move :dev, commit last, tag. Adapted, not copied. The arguments are PrivacyLLC's and were paid for there; the mechanism is this project's, because almost none of it transferred. Three differences, each a fact about this repository rather than a preference: - It gates on verify.sh, not a test suite, because there is not one. The original refuses to release on a half-run 1,600-test run. This one says out loud that a build, a secret scan and a doc-header check are not tests and that nothing in the gate touched a route, a form or an API response. - It moves :dev as well as publishing :vX.Y.Z, because Portainer stack 58 follows :dev. That makes :dev a pointer and never evidence. - It checks the public origin in three files, not one. The original passes its origin in as a build arg; here https://queuenorth.com is written out in src/lib/seo.js, src/components/SEO.jsx and scripts/prerender.js and is baked into every canonical URL, og:url, sitemap.xml and robots.txt. The guard asks whether the three still agree. Why this was needed: publishing was `npm run docker:push` with the bump as a separate thing to remember, and it was not remembered. package.json said 0.8.3 while four commits announced "batch 0.9.0" through "0.9.3", and NO image was ever published for any of them — the registry's newest tag is v0.8.3. No prune. The original has one; this project has published thirteen tags in its life, deleting a published image is irreversible, and the one that matters is whichever the container was created from — exactly what a newest-N rule gets wrong. scripts/deploy.sh — deploy, and do not build. The template's deploy.py builds, pushes AND deploys; adopting it beside release.sh would mean two commands that both build, a second image for the same code, and two answers to "what is running". This does only the missing half: point stack 58 at an already published tag, having taken a verified backup first. Its most important guard is not the obvious one. Portainer treats a stack PUT as the whole desired state, so sending it without the stack's Env array would strip twelve variables — the reCAPTCHA secret and the Zoho form tokens among them — and the container would come back HEALTHY while quietly capturing no leads. It reads them, counts them, sends them back, and refuses outright if none. Guards proven to refuse, not assumed: already-published tag, http origin, trailing-slash origin, the three origin constants drifted, dirty tree, bad flag, unpublished deploy tag, missing Portainer key, wrong stack id. One real bug found and fixed while testing: the image-line rewrite used `python3 -` with a heredoc while also piping the stack file to stdin, so python tried to execute the YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:49:03 -05:00
say " git commit -m 'chore(release): ${TAG}' (post-commit then pushes)"
say " git tag ${TAG} && git push origin ${TAG}"
say ""
feat(deploy): production always runs a numbered version Policy set by _null. The stack pins :vX.Y.Z — never :dev, never :latest. The reason is what a pointer cannot do. A stack file naming :dev says "dev" today and will still say "dev" after the image underneath it changes, so the file records a preference rather than a fact and "what is running?" has no answer that survives the next push. Rollback is worse: there is nothing to roll back TO, because a pointer only ever names now. deploy.sh: with no --tag it now resolves the newest published NUMBERED version from the registry and says which it picked, instead of defaulting to :dev. A floating tag is refused outright, with --allow-floating as the loud escape hatch for the one case that is not a mistake — reproducing a fault on whatever a pointer currently is. release.sh: no longer moves :dev. It published both tags until now because the stack followed the pointer; under this policy nothing deploys :dev, so moving it would only publish something that misrepresents what is running. scripts/docker-push.sh deleted, with its npm script. Its entire job was building and pushing an unnumbered :dev with no version bump and no guards, which is now the exact thing the policy exists to prevent. Keeping a command that quietly violates a policy is how the policy stops being true. :dev and :latest stay in the registry, frozen. Not deleted: the running container was created from :dev, and removing the tag an existing deployment names is how a recreate fails to pull. Stack 58 still pins :dev as this lands. Correcting that is the first deploy made under the policy, and it is why the default resolves a version rather than reusing whatever the stack already names. Guards proven: default resolves v0.8.3; --tag dev refused; --tag latest refused; --allow-floating warns and proceeds; unpublished tag still refused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:54:42 -05:00
say "It would NOT deploy, and it would NOT move :dev — production runs a"
say "numbered version. Afterwards: npm run deploy -- --tag ${TAG}"
feat(release): adapt PrivacyLLC-Web's release.sh, and add the deploy half it deliberately omits scripts/release.sh — publish. Bump, guards, build, verify the image's own version label, push :vX.Y.Z and move :dev, commit last, tag. Adapted, not copied. The arguments are PrivacyLLC's and were paid for there; the mechanism is this project's, because almost none of it transferred. Three differences, each a fact about this repository rather than a preference: - It gates on verify.sh, not a test suite, because there is not one. The original refuses to release on a half-run 1,600-test run. This one says out loud that a build, a secret scan and a doc-header check are not tests and that nothing in the gate touched a route, a form or an API response. - It moves :dev as well as publishing :vX.Y.Z, because Portainer stack 58 follows :dev. That makes :dev a pointer and never evidence. - It checks the public origin in three files, not one. The original passes its origin in as a build arg; here https://queuenorth.com is written out in src/lib/seo.js, src/components/SEO.jsx and scripts/prerender.js and is baked into every canonical URL, og:url, sitemap.xml and robots.txt. The guard asks whether the three still agree. Why this was needed: publishing was `npm run docker:push` with the bump as a separate thing to remember, and it was not remembered. package.json said 0.8.3 while four commits announced "batch 0.9.0" through "0.9.3", and NO image was ever published for any of them — the registry's newest tag is v0.8.3. No prune. The original has one; this project has published thirteen tags in its life, deleting a published image is irreversible, and the one that matters is whichever the container was created from — exactly what a newest-N rule gets wrong. scripts/deploy.sh — deploy, and do not build. The template's deploy.py builds, pushes AND deploys; adopting it beside release.sh would mean two commands that both build, a second image for the same code, and two answers to "what is running". This does only the missing half: point stack 58 at an already published tag, having taken a verified backup first. Its most important guard is not the obvious one. Portainer treats a stack PUT as the whole desired state, so sending it without the stack's Env array would strip twelve variables — the reCAPTCHA secret and the Zoho form tokens among them — and the container would come back HEALTHY while quietly capturing no leads. It reads them, counts them, sends them back, and refuses outright if none. Guards proven to refuse, not assumed: already-published tag, http origin, trailing-slash origin, the three origin constants drifted, dirty tree, bad flag, unpublished deploy tag, missing Portainer key, wrong stack id. One real bug found and fixed while testing: the image-line rewrite used `python3 -` with a heredoc while also piping the stack file to stdin, so python tried to execute the YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:49:03 -05:00
exit 0
fi
# ---------------------------------------------------------------------------
# Bump. Files first, so the image is built from the source that names it.
# ---------------------------------------------------------------------------
npm version --no-git-tag-version --allow-same-version "$next" >/dev/null \
|| die "npm version failed; nothing has been built or pushed."
perl -pi -e "s{^ARG APP_VERSION=.*}{ARG APP_VERSION=${next}}" Dockerfile \
|| die "could not rewrite ARG APP_VERSION in the Dockerfile."
# Checked, not assumed. A rewrite that silently matched nothing would publish an
# image whose version label names the previous release.
grep -q "^ARG APP_VERSION=${next}\$" Dockerfile \
|| die "ARG APP_VERSION in the Dockerfile did not update — refusing to build.
The bump is in your working tree; 'git checkout -- ${FILES[*]}' undoes it."
say "bumped ${FILES[*]}"
# ---------------------------------------------------------------------------
# Guards, before anything is built.
#
# PrivacyLLC's version runs a typecheck and a 1,600-test suite here, and refuses
# to release on a half-run one. **This project has neither**, and that is stated
# rather than papered over: scripts/verify.sh runs the build, the tracked-tree
# secret scan and the document-header check, and none of those is a test.
#
# So this gate proves the artifact compiles and carries no credential. It does
# not prove the site works. docs/qa/ClaudeQACoverage.md is honest about what
# that leaves untested, which is nearly everything.
# ---------------------------------------------------------------------------
say "guards…"
if ! bash scripts/verify.sh; then
say "verify.sh failed. The version bump is in your working tree and NOTHING was"
say " built, published or committed. Fix it and run again, or"
say " 'git checkout -- ${FILES[*]}' to undo the bump."
exit 1
fi
say "NOTE: those guards are a build, a secret scan and a doc-header check."
say " There is no test suite in this repository, so nothing above"
say " exercised a single route, form or API response."
# ---------------------------------------------------------------------------
# Build, verify what came out, then push. Nothing is committed until all three
# have succeeded.
# ---------------------------------------------------------------------------
say "building ${IMAGE}:${TAG}"
if ! docker build \
--build-arg "APP_VERSION=${next}" \
--build-arg "VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-}" \
-t "${IMAGE}:${TAG}" . ; then
say "build failed. The version bump is in your working tree and NOTHING was"
say " published or committed. 'git checkout -- ${FILES[*]}' undoes it."
exit 1
fi
# Ask the image rather than trusting the wiring. The Dockerfile threads
# APP_VERSION through an ARG into a LABEL; break either and the build still
# succeeds, and the only symptom is `status.sh --deployed-version` reporting the
# wrong version during an incident — which is the moment it is trusted most.
say "verifying the image reports ${next}"
baked=$(docker image inspect "${IMAGE}:${TAG}" \
--format '{{index .Config.Labels "org.opencontainers.image.version"}}' 2>/dev/null | tr -d '\r\n')
if [ "$baked" != "$next" ]; then
# Remove the local tag. A correctly-named image with the wrong contents is a
# loaded gun for a later hand-typed `docker push`.
docker rmi "${IMAGE}:${TAG}" >/dev/null 2>&1
say "the image's version label reads '${baked:-nothing}' but should read ${next}."
say " Check ARG APP_VERSION and the LABEL in the runner stage of the"
say " Dockerfile. Nothing was published or committed; the local image"
say " was removed."
exit 1
fi
fix(deploy): stop two ways this tooling would have broken production Both found by checking before running, not by running. 1. release.sh would have shipped a site whose contact form cannot be submitted. .dockerignore excludes .env from the build context, and the script passed --build-arg VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-} without ever loading .env — and the variable is unset in every shell. The `:-` made empty silently acceptable. An empty key makes RecaptchaPlaceholder render "Security verification is not configured." and produce no token, and the server has RECAPTCHA_ENABLED=true, so every submission is rejected. Lead capture stops. Now: loads VITE_* from .env, refuses an empty key outright, and greps the BUILT image's bundle for it before pushing — ask the artifact, do not trust the wiring, the same move already used for the version label. 2. deploy.sh's default would have rolled production back two months. The newest published NUMBERED tag is v0.8.3, built 2026-05-28; the running :dev image was built 2026-08-01. v0.8.3 has no privacy policy and no prerendered routes at all. Now: the target's build date is read from the registry without pulling it, compared against what is running, and refused if older unless --rollback is passed. Also in deploy.sh: - --fix-cors removes the trailing slash from CORS_ORIGIN (#212), and --watchtower-off adds com.centurylinklabs.watchtower.enable=false. Both ride in the same PUT so production restarts once, not three times. - The stack file is now read to a temp file instead of $( ), which was stripping its trailing newline — a change beyond the lines the script claims to touch. - The env-line count is asserted before sending. Verified with `docker compose config` why that matters: without the Env array the ${VAR:-false} defaults resolve reCAPTCHA and Zoho WebToLead to false, so the container would come back healthy and quietly stop capturing leads. - Post-deploy it checks health, both origins, AND that the live bundle carries the site key the stack declares — the failure no health check can see. - Any of those failing triggers an automatic rollback to the original stack bytes, once. If the rollback also fails it stops and says so rather than retrying, because a script retrying an outage is how a short one becomes long. The resulting stack file was validated with `docker compose config`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:33:20 -05:00
# Ask the artifact, not the wiring — the same move the version check above
# makes, for the value that actually stops the product working. This survives
# somebody editing the Dockerfile's ARG/ENV pair or adding dist to
# .dockerignore, neither of which would fail the build.
say "verifying the bundle carries the reCAPTCHA site key…"
if ! docker run --rm --entrypoint sh "${IMAGE}:${TAG}" -c \
"grep -lqF '${VITE_RECAPTCHA_SITE_KEY}' /app/dist/assets/*.js" >/dev/null 2>&1; then
docker rmi "${IMAGE}:${TAG}" >/dev/null 2>&1
say "the built bundle does NOT contain the reCAPTCHA site key."
say " This image would render \"Security verification is not configured.\""
say " on the contact form and reject every submission. Check ARG/ENV"
say " VITE_RECAPTCHA_SITE_KEY in the builder stage of the Dockerfile."
say " Nothing was published or committed; the local image was removed."
exit 1
fi
feat(release): adapt PrivacyLLC-Web's release.sh, and add the deploy half it deliberately omits scripts/release.sh — publish. Bump, guards, build, verify the image's own version label, push :vX.Y.Z and move :dev, commit last, tag. Adapted, not copied. The arguments are PrivacyLLC's and were paid for there; the mechanism is this project's, because almost none of it transferred. Three differences, each a fact about this repository rather than a preference: - It gates on verify.sh, not a test suite, because there is not one. The original refuses to release on a half-run 1,600-test run. This one says out loud that a build, a secret scan and a doc-header check are not tests and that nothing in the gate touched a route, a form or an API response. - It moves :dev as well as publishing :vX.Y.Z, because Portainer stack 58 follows :dev. That makes :dev a pointer and never evidence. - It checks the public origin in three files, not one. The original passes its origin in as a build arg; here https://queuenorth.com is written out in src/lib/seo.js, src/components/SEO.jsx and scripts/prerender.js and is baked into every canonical URL, og:url, sitemap.xml and robots.txt. The guard asks whether the three still agree. Why this was needed: publishing was `npm run docker:push` with the bump as a separate thing to remember, and it was not remembered. package.json said 0.8.3 while four commits announced "batch 0.9.0" through "0.9.3", and NO image was ever published for any of them — the registry's newest tag is v0.8.3. No prune. The original has one; this project has published thirteen tags in its life, deleting a published image is irreversible, and the one that matters is whichever the container was created from — exactly what a newest-N rule gets wrong. scripts/deploy.sh — deploy, and do not build. The template's deploy.py builds, pushes AND deploys; adopting it beside release.sh would mean two commands that both build, a second image for the same code, and two answers to "what is running". This does only the missing half: point stack 58 at an already published tag, having taken a verified backup first. Its most important guard is not the obvious one. Portainer treats a stack PUT as the whole desired state, so sending it without the stack's Env array would strip twelve variables — the reCAPTCHA secret and the Zoho form tokens among them — and the container would come back HEALTHY while quietly capturing no leads. It reads them, counts them, sends them back, and refuses outright if none. Guards proven to refuse, not assumed: already-published tag, http origin, trailing-slash origin, the three origin constants drifted, dirty tree, bad flag, unpublished deploy tag, missing Portainer key, wrong stack id. One real bug found and fixed while testing: the image-line rewrite used `python3 -` with a heredoc while also piping the stack file to stdin, so python tried to execute the YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:49:03 -05:00
say "pushing ${IMAGE}:${TAG}"
if ! docker push "${IMAGE}:${TAG}"; then
say "push failed. The image exists locally and the bump is in your working"
say " tree, but nothing was published or committed. Check the registry"
say " login (docker login ${IMAGE%%/*}) and run this again."
exit 1
fi
# ---------------------------------------------------------------------------
# Commit last, by explicit path.
# ---------------------------------------------------------------------------
git add -- "${FILES[@]}" || die "git add failed after a successful push — commit ${FILES[*]} by hand."
if ! git commit -q -m "chore(release): ${TAG}"; then
say "the commit was refused (see above). The image IS published as ${TAG};"
say " only the commit is missing. Fix and commit ${FILES[*]} by hand."
exit 1
fi
# After the commit, so the tag has something to point at. post-commit pushes the
# branch and never --tags, so the tag needs a push of its own.
if git tag "${TAG}" >/dev/null 2>&1 && git push -q origin "${TAG}" >/dev/null 2>&1; then
say "tagged ${TAG}."
else
# Not fatal. A release claims an image was built, checked and published; all
# three are true by now. A missing tag does not make any of them false.
say "note: the git tag was not created or not pushed. The image IS published"
say " and the commit is made. Run: git tag ${TAG} && git push origin ${TAG}"
fi
say "released ${TAG}."
say ""
say "This published an image. It did NOT deploy it — Portainer stack 58 is still"
feat(deploy): production always runs a numbered version Policy set by _null. The stack pins :vX.Y.Z — never :dev, never :latest. The reason is what a pointer cannot do. A stack file naming :dev says "dev" today and will still say "dev" after the image underneath it changes, so the file records a preference rather than a fact and "what is running?" has no answer that survives the next push. Rollback is worse: there is nothing to roll back TO, because a pointer only ever names now. deploy.sh: with no --tag it now resolves the newest published NUMBERED version from the registry and says which it picked, instead of defaulting to :dev. A floating tag is refused outright, with --allow-floating as the loud escape hatch for the one case that is not a mistake — reproducing a fault on whatever a pointer currently is. release.sh: no longer moves :dev. It published both tags until now because the stack followed the pointer; under this policy nothing deploys :dev, so moving it would only publish something that misrepresents what is running. scripts/docker-push.sh deleted, with its npm script. Its entire job was building and pushing an unnumbered :dev with no version bump and no guards, which is now the exact thing the policy exists to prevent. Keeping a command that quietly violates a policy is how the policy stops being true. :dev and :latest stay in the registry, frozen. Not deleted: the running container was created from :dev, and removing the tag an existing deployment names is how a recreate fails to pull. Stack 58 still pins :dev as this lands. Correcting that is the first deploy made under the policy, and it is why the default resolves a version rather than reusing whatever the stack already names. Guards proven: default resolves v0.8.3; --tag dev refused; --tag latest refused; --allow-floating warns and proceeds; unpublished tag still refused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:54:42 -05:00
say "running whatever it was running before. To move it to this version:"
feat(release): adapt PrivacyLLC-Web's release.sh, and add the deploy half it deliberately omits scripts/release.sh — publish. Bump, guards, build, verify the image's own version label, push :vX.Y.Z and move :dev, commit last, tag. Adapted, not copied. The arguments are PrivacyLLC's and were paid for there; the mechanism is this project's, because almost none of it transferred. Three differences, each a fact about this repository rather than a preference: - It gates on verify.sh, not a test suite, because there is not one. The original refuses to release on a half-run 1,600-test run. This one says out loud that a build, a secret scan and a doc-header check are not tests and that nothing in the gate touched a route, a form or an API response. - It moves :dev as well as publishing :vX.Y.Z, because Portainer stack 58 follows :dev. That makes :dev a pointer and never evidence. - It checks the public origin in three files, not one. The original passes its origin in as a build arg; here https://queuenorth.com is written out in src/lib/seo.js, src/components/SEO.jsx and scripts/prerender.js and is baked into every canonical URL, og:url, sitemap.xml and robots.txt. The guard asks whether the three still agree. Why this was needed: publishing was `npm run docker:push` with the bump as a separate thing to remember, and it was not remembered. package.json said 0.8.3 while four commits announced "batch 0.9.0" through "0.9.3", and NO image was ever published for any of them — the registry's newest tag is v0.8.3. No prune. The original has one; this project has published thirteen tags in its life, deleting a published image is irreversible, and the one that matters is whichever the container was created from — exactly what a newest-N rule gets wrong. scripts/deploy.sh — deploy, and do not build. The template's deploy.py builds, pushes AND deploys; adopting it beside release.sh would mean two commands that both build, a second image for the same code, and two answers to "what is running". This does only the missing half: point stack 58 at an already published tag, having taken a verified backup first. Its most important guard is not the obvious one. Portainer treats a stack PUT as the whole desired state, so sending it without the stack's Env array would strip twelve variables — the reCAPTCHA secret and the Zoho form tokens among them — and the container would come back HEALTHY while quietly capturing no leads. It reads them, counts them, sends them back, and refuses outright if none. Guards proven to refuse, not assumed: already-published tag, http origin, trailing-slash origin, the three origin constants drifted, dirty tree, bad flag, unpublished deploy tag, missing Portainer key, wrong stack id. One real bug found and fixed while testing: the image-line rewrite used `python3 -` with a heredoc while also piping the stack file to stdin, so python tried to execute the YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:49:03 -05:00
say ""
feat(deploy): production always runs a numbered version Policy set by _null. The stack pins :vX.Y.Z — never :dev, never :latest. The reason is what a pointer cannot do. A stack file naming :dev says "dev" today and will still say "dev" after the image underneath it changes, so the file records a preference rather than a fact and "what is running?" has no answer that survives the next push. Rollback is worse: there is nothing to roll back TO, because a pointer only ever names now. deploy.sh: with no --tag it now resolves the newest published NUMBERED version from the registry and says which it picked, instead of defaulting to :dev. A floating tag is refused outright, with --allow-floating as the loud escape hatch for the one case that is not a mistake — reproducing a fault on whatever a pointer currently is. release.sh: no longer moves :dev. It published both tags until now because the stack followed the pointer; under this policy nothing deploys :dev, so moving it would only publish something that misrepresents what is running. scripts/docker-push.sh deleted, with its npm script. Its entire job was building and pushing an unnumbered :dev with no version bump and no guards, which is now the exact thing the policy exists to prevent. Keeping a command that quietly violates a policy is how the policy stops being true. :dev and :latest stay in the registry, frozen. Not deleted: the running container was created from :dev, and removing the tag an existing deployment names is how a recreate fails to pull. Stack 58 still pins :dev as this lands. Correcting that is the first deploy made under the policy, and it is why the default resolves a version rather than reusing whatever the stack already names. Guards proven: default resolves v0.8.3; --tag dev refused; --tag latest refused; --allow-floating warns and proceeds; unpublished tag still refused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:54:42 -05:00
say " npm run deploy -- --tag ${TAG} --dry-run"
say " npm run deploy -- --tag ${TAG}"
feat(release): adapt PrivacyLLC-Web's release.sh, and add the deploy half it deliberately omits scripts/release.sh — publish. Bump, guards, build, verify the image's own version label, push :vX.Y.Z and move :dev, commit last, tag. Adapted, not copied. The arguments are PrivacyLLC's and were paid for there; the mechanism is this project's, because almost none of it transferred. Three differences, each a fact about this repository rather than a preference: - It gates on verify.sh, not a test suite, because there is not one. The original refuses to release on a half-run 1,600-test run. This one says out loud that a build, a secret scan and a doc-header check are not tests and that nothing in the gate touched a route, a form or an API response. - It moves :dev as well as publishing :vX.Y.Z, because Portainer stack 58 follows :dev. That makes :dev a pointer and never evidence. - It checks the public origin in three files, not one. The original passes its origin in as a build arg; here https://queuenorth.com is written out in src/lib/seo.js, src/components/SEO.jsx and scripts/prerender.js and is baked into every canonical URL, og:url, sitemap.xml and robots.txt. The guard asks whether the three still agree. Why this was needed: publishing was `npm run docker:push` with the bump as a separate thing to remember, and it was not remembered. package.json said 0.8.3 while four commits announced "batch 0.9.0" through "0.9.3", and NO image was ever published for any of them — the registry's newest tag is v0.8.3. No prune. The original has one; this project has published thirteen tags in its life, deleting a published image is irreversible, and the one that matters is whichever the container was created from — exactly what a newest-N rule gets wrong. scripts/deploy.sh — deploy, and do not build. The template's deploy.py builds, pushes AND deploys; adopting it beside release.sh would mean two commands that both build, a second image for the same code, and two answers to "what is running". This does only the missing half: point stack 58 at an already published tag, having taken a verified backup first. Its most important guard is not the obvious one. Portainer treats a stack PUT as the whole desired state, so sending it without the stack's Env array would strip twelve variables — the reCAPTCHA secret and the Zoho form tokens among them — and the container would come back HEALTHY while quietly capturing no leads. It reads them, counts them, sends them back, and refuses outright if none. Guards proven to refuse, not assumed: already-published tag, http origin, trailing-slash origin, the three origin constants drifted, dirty tree, bad flag, unpublished deploy tag, missing Portainer key, wrong stack id. One real bug found and fixed while testing: the image-line rewrite used `python3 -` with a heredoc while also piping the stack file to stdin, so python tried to execute the YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:49:03 -05:00
say ""
say "and see docs/OPERATIONS.md, which covers what a redeploy costs: it recreates"
say "the container and takes both public front doors down together."