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
|
|
|
|
|
|
fix(seo): the production sitemap had no dates, and the build context had secrets
Three things, all in the path between this repository and the running image.
Closes #225, #224 and #223.
1. THE PRODUCTION SITEMAP CARRIED NO LASTMOD AT ALL. Dates come from git
history, and the image build cannot see git: .dockerignore excludes .git and
node:alpine has no git binary. prerender.js read the failure into an empty
catch commented "git unavailable or file untracked", so all 18 URLs came out
undated while the build printed a success line. Local builds looked perfect,
which is why nobody caught it.
release.sh now computes the map where git exists, passes it as the
SITEMAP_LASTMOD build arg, and then asks the built image whether its sitemap
has dates, refusing to publish one that does not. prerender prints the count
on every run, so "18 URLs, 0 dated" can never again read as success. The
route-to-source map moved into scripts/lib/routes.js, where a service page
now also counts its own content file, so editing one page's copy moves that
page's date and no other.
Proven: an image built with the arg carries 18 lastmod entries; a build with
git deliberately unreadable and no arg reports "18 URLs, 0 carrying a
lastmod" and warns.
2. THE DOCKER BUILD CONTEXT CARRIED CLIENT MATERIAL AND LIVE SECRETS. .drop/,
zoho.md (the reCAPTCHA secret and the Zoho tokens), Levi.md and two 30 MB
zips were all sent to the daemon on every build, along with four agent
workspaces. The final image copies only built output, so none of it ever
shipped, but one careless COPY would have changed that. Proven by listing the
context from inside a throwaway image: before, all of it; after, none of it.
3. UNTRACKED FILES PASSED SILENTLY. docker build packs the working tree, so an
untracked module the code imports produces an image that works and a tag that
cannot rebuild it. release.sh now refuses while untracked files are present,
and pre-commit's note counts them too.
#223 also claimed post-commit hides a refused push. It does not: it printed
"push was refused. The commit is safe locally and the branch is now ahead."
during this batch. The issue was corrected on the tracker rather than acted on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:58:37 -05:00
|
|
|
# Untracked files are checked separately, and they matter more than they look.
|
|
|
|
|
# `docker build .` packs the WORKING TREE, not the commit: an untracked module
|
|
|
|
|
# that the code imports produces an image that works and a tagged commit that
|
|
|
|
|
# cannot be rebuilt. The tag is supposed to be the record of what shipped.
|
|
|
|
|
untracked=$(git ls-files --others --exclude-standard)
|
|
|
|
|
if [ -n "$untracked" ]; then
|
|
|
|
|
say "the working tree has untracked files:"
|
|
|
|
|
printf '%s\n' "$untracked" | sed 's/^/ /' >&2
|
|
|
|
|
die "add or remove them first. docker build packs the working tree, so these
|
|
|
|
|
would go into the image while the tag could not rebuild it."
|
|
|
|
|
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
|
|
|
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"
|
fix(seo): the production sitemap had no dates, and the build context had secrets
Three things, all in the path between this repository and the running image.
Closes #225, #224 and #223.
1. THE PRODUCTION SITEMAP CARRIED NO LASTMOD AT ALL. Dates come from git
history, and the image build cannot see git: .dockerignore excludes .git and
node:alpine has no git binary. prerender.js read the failure into an empty
catch commented "git unavailable or file untracked", so all 18 URLs came out
undated while the build printed a success line. Local builds looked perfect,
which is why nobody caught it.
release.sh now computes the map where git exists, passes it as the
SITEMAP_LASTMOD build arg, and then asks the built image whether its sitemap
has dates, refusing to publish one that does not. prerender prints the count
on every run, so "18 URLs, 0 dated" can never again read as success. The
route-to-source map moved into scripts/lib/routes.js, where a service page
now also counts its own content file, so editing one page's copy moves that
page's date and no other.
Proven: an image built with the arg carries 18 lastmod entries; a build with
git deliberately unreadable and no arg reports "18 URLs, 0 carrying a
lastmod" and warns.
2. THE DOCKER BUILD CONTEXT CARRIED CLIENT MATERIAL AND LIVE SECRETS. .drop/,
zoho.md (the reCAPTCHA secret and the Zoho tokens), Levi.md and two 30 MB
zips were all sent to the daemon on every build, along with four agent
workspaces. The final image copies only built output, so none of it ever
shipped, but one careless COPY would have changed that. Proven by listing the
context from inside a throwaway image: before, all of it; after, none of it.
3. UNTRACKED FILES PASSED SILENTLY. docker build packs the working tree, so an
untracked module the code imports produces an image that works and a tag that
cannot rebuild it. release.sh now refuses while untracked files are present,
and pre-commit's note counts them too.
#223 also claimed post-commit hides a refused push. It does not: it printed
"push was refused. The commit is safe locally and the branch is now ahead."
during this batch. The issue was corrected on the tracker rather than acted on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:58:37 -05:00
|
|
|
say " docker build --build-arg APP_VERSION=${next} --build-arg SITEMAP_LASTMOD=<dates> -t ${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 " verify the image's org.opencontainers.image.version label reads ${next}"
|
fix(seo): the production sitemap had no dates, and the build context had secrets
Three things, all in the path between this repository and the running image.
Closes #225, #224 and #223.
1. THE PRODUCTION SITEMAP CARRIED NO LASTMOD AT ALL. Dates come from git
history, and the image build cannot see git: .dockerignore excludes .git and
node:alpine has no git binary. prerender.js read the failure into an empty
catch commented "git unavailable or file untracked", so all 18 URLs came out
undated while the build printed a success line. Local builds looked perfect,
which is why nobody caught it.
release.sh now computes the map where git exists, passes it as the
SITEMAP_LASTMOD build arg, and then asks the built image whether its sitemap
has dates, refusing to publish one that does not. prerender prints the count
on every run, so "18 URLs, 0 dated" can never again read as success. The
route-to-source map moved into scripts/lib/routes.js, where a service page
now also counts its own content file, so editing one page's copy moves that
page's date and no other.
Proven: an image built with the arg carries 18 lastmod entries; a build with
git deliberately unreadable and no arg reports "18 URLs, 0 carrying a
lastmod" and warns.
2. THE DOCKER BUILD CONTEXT CARRIED CLIENT MATERIAL AND LIVE SECRETS. .drop/,
zoho.md (the reCAPTCHA secret and the Zoho tokens), Levi.md and two 30 MB
zips were all sent to the daemon on every build, along with four agent
workspaces. The final image copies only built output, so none of it ever
shipped, but one careless COPY would have changed that. Proven by listing the
context from inside a throwaway image: before, all of it; after, none of it.
3. UNTRACKED FILES PASSED SILENTLY. docker build packs the working tree, so an
untracked module the code imports produces an image that works and a tag that
cannot rebuild it. release.sh now refuses while untracked files are present,
and pre-commit's note counts them too.
#223 also claimed post-commit hides a refused push. It does not: it printed
"push was refused. The commit is safe locally and the branch is now ahead."
during this batch. The issue was corrected on the tracker rather than acted on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:58:37 -05:00
|
|
|
say " verify the image's sitemap carries dates"
|
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
|
|
|
|
|
|
feat(build): audit what the site actually serves, in the build and on the wire
Everything here checked an input: the content check reads the data, the secret
scan reads the diff, the build reads the source. Nothing read the OUTPUT, which
is the only thing a visitor or a crawler ever sees. Two live defects made the
case: every page preloaded the wrong image for months, and eleven pages shipped
a run-on description. Both are plain in the built HTML and invisible in the
source.
Build mode is guard 15-built-html, after 10-build. Per page it requires exactly
one title, one non-empty description, one canonical equal to the site origin plus
the route, and one h1; JSON-LD that parses, with no FAQPage, which the owner
ruled out; no em dash and no U+FFFD; a preload naming the image the page actually
paints first; and no description that runs its short description into the next
sentence. Across pages it requires every internal link and every fragment to
resolve, the sitemap to list exactly the routes the site serves, and 404.html to
carry noindex and no canonical. It exits 2, not 0, when dist/ is missing or older
than the sources: auditing stale output is auditing nothing.
That also guards a specific hazard. react-helmet-async on React 19 does not
merge, so a second <SEO> anywhere on a page silently emits a second title and a
second canonical, and a search engine picks whichever it likes.
URL mode fetches every page in a live sitemap once per crawler user agent
(OAI-SearchBot, PerplexityBot, ClaudeBot, Googlebot, bingbot), requires HTTP 200
and identical bytes across agents, runs the same page rules, and reports any URL
without a lastmod. It is deliberately NOT wired into deploy.sh: a check that runs
after publication cannot stop it, and pretending otherwise is worse than not
having it. Run it after a deploy.
Proven by mutation, nine of them, each restored afterwards: a wrong canonical
(7 pages), a second h1 (4), FAQPage markup, an em dash in copy, a link to a route
that does not exist (18), a fragment that is not on its target page (7), the
preload keyed on the old attribute (19), the template title left in place giving
two titles (19), and the industry routes dropped from the route list (56). A
clean build audits clean, and URL mode passes against the local server as all
five crawlers.
Closes #228.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:51:08 -05:00
|
|
|
say "NOTE: those guards are a build (which validates the content layer), an"
|
|
|
|
|
say " audit of the built HTML, a secret scan of the tree and the bundle,"
|
|
|
|
|
say " and a doc-header check. There is no test suite in this repository,"
|
|
|
|
|
say " so nothing above exercised a form or an API response, and nothing"
|
|
|
|
|
say " loaded a page in a browser."
|
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
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Build, verify what came out, then push. Nothing is committed until all three
|
|
|
|
|
# have succeeded.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
say "building ${IMAGE}:${TAG}…"
|
|
|
|
|
|
fix(seo): the production sitemap had no dates, and the build context had secrets
Three things, all in the path between this repository and the running image.
Closes #225, #224 and #223.
1. THE PRODUCTION SITEMAP CARRIED NO LASTMOD AT ALL. Dates come from git
history, and the image build cannot see git: .dockerignore excludes .git and
node:alpine has no git binary. prerender.js read the failure into an empty
catch commented "git unavailable or file untracked", so all 18 URLs came out
undated while the build printed a success line. Local builds looked perfect,
which is why nobody caught it.
release.sh now computes the map where git exists, passes it as the
SITEMAP_LASTMOD build arg, and then asks the built image whether its sitemap
has dates, refusing to publish one that does not. prerender prints the count
on every run, so "18 URLs, 0 dated" can never again read as success. The
route-to-source map moved into scripts/lib/routes.js, where a service page
now also counts its own content file, so editing one page's copy moves that
page's date and no other.
Proven: an image built with the arg carries 18 lastmod entries; a build with
git deliberately unreadable and no arg reports "18 URLs, 0 carrying a
lastmod" and warns.
2. THE DOCKER BUILD CONTEXT CARRIED CLIENT MATERIAL AND LIVE SECRETS. .drop/,
zoho.md (the reCAPTCHA secret and the Zoho tokens), Levi.md and two 30 MB
zips were all sent to the daemon on every build, along with four agent
workspaces. The final image copies only built output, so none of it ever
shipped, but one careless COPY would have changed that. Proven by listing the
context from inside a throwaway image: before, all of it; after, none of it.
3. UNTRACKED FILES PASSED SILENTLY. docker build packs the working tree, so an
untracked module the code imports produces an image that works and a tag that
cannot rebuild it. release.sh now refuses while untracked files are present,
and pre-commit's note counts them too.
#223 also claimed post-commit hides a refused push. It does not: it printed
"push was refused. The commit is safe locally and the branch is now ahead."
during this batch. The issue was corrected on the tracker rather than acted on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:58:37 -05:00
|
|
|
# Sitemap dates come from git history, and the image build cannot see it:
|
|
|
|
|
# .dockerignore excludes .git and node:alpine ships no git binary. Computing the
|
|
|
|
|
# map here, where git exists, and passing it in is what puts dates in the
|
|
|
|
|
# production sitemap. Without it every URL goes out undated, which is exactly
|
|
|
|
|
# what production served until 2026-09-10.
|
|
|
|
|
sitemap_lastmod=$(node -e "import('./scripts/lib/routes.js').then(m => console.log(JSON.stringify(m.lastModByRoute())))" 2>/dev/null)
|
|
|
|
|
if [ -z "$sitemap_lastmod" ] || [ "$sitemap_lastmod" = "{}" ]; then
|
|
|
|
|
die "could not compute sitemap dates from git history, so the image would ship an
|
|
|
|
|
undated sitemap. Run this from a full clone, not an export."
|
|
|
|
|
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
|
|
|
if ! docker build \
|
|
|
|
|
--build-arg "APP_VERSION=${next}" \
|
|
|
|
|
--build-arg "VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-}" \
|
fix(seo): the production sitemap had no dates, and the build context had secrets
Three things, all in the path between this repository and the running image.
Closes #225, #224 and #223.
1. THE PRODUCTION SITEMAP CARRIED NO LASTMOD AT ALL. Dates come from git
history, and the image build cannot see git: .dockerignore excludes .git and
node:alpine has no git binary. prerender.js read the failure into an empty
catch commented "git unavailable or file untracked", so all 18 URLs came out
undated while the build printed a success line. Local builds looked perfect,
which is why nobody caught it.
release.sh now computes the map where git exists, passes it as the
SITEMAP_LASTMOD build arg, and then asks the built image whether its sitemap
has dates, refusing to publish one that does not. prerender prints the count
on every run, so "18 URLs, 0 dated" can never again read as success. The
route-to-source map moved into scripts/lib/routes.js, where a service page
now also counts its own content file, so editing one page's copy moves that
page's date and no other.
Proven: an image built with the arg carries 18 lastmod entries; a build with
git deliberately unreadable and no arg reports "18 URLs, 0 carrying a
lastmod" and warns.
2. THE DOCKER BUILD CONTEXT CARRIED CLIENT MATERIAL AND LIVE SECRETS. .drop/,
zoho.md (the reCAPTCHA secret and the Zoho tokens), Levi.md and two 30 MB
zips were all sent to the daemon on every build, along with four agent
workspaces. The final image copies only built output, so none of it ever
shipped, but one careless COPY would have changed that. Proven by listing the
context from inside a throwaway image: before, all of it; after, none of it.
3. UNTRACKED FILES PASSED SILENTLY. docker build packs the working tree, so an
untracked module the code imports produces an image that works and a tag that
cannot rebuild it. release.sh now refuses while untracked files are present,
and pre-commit's note counts them too.
#223 also claimed post-commit hides a refused push. It does not: it printed
"push was refused. The commit is safe locally and the branch is now ahead."
during this batch. The issue was corrected on the tracker rather than acted on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:58:37 -05:00
|
|
|
--build-arg "SITEMAP_LASTMOD=${sitemap_lastmod}" \
|
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
|
|
|
-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.
|
fix(seo): the production sitemap had no dates, and the build context had secrets
Three things, all in the path between this repository and the running image.
Closes #225, #224 and #223.
1. THE PRODUCTION SITEMAP CARRIED NO LASTMOD AT ALL. Dates come from git
history, and the image build cannot see git: .dockerignore excludes .git and
node:alpine has no git binary. prerender.js read the failure into an empty
catch commented "git unavailable or file untracked", so all 18 URLs came out
undated while the build printed a success line. Local builds looked perfect,
which is why nobody caught it.
release.sh now computes the map where git exists, passes it as the
SITEMAP_LASTMOD build arg, and then asks the built image whether its sitemap
has dates, refusing to publish one that does not. prerender prints the count
on every run, so "18 URLs, 0 dated" can never again read as success. The
route-to-source map moved into scripts/lib/routes.js, where a service page
now also counts its own content file, so editing one page's copy moves that
page's date and no other.
Proven: an image built with the arg carries 18 lastmod entries; a build with
git deliberately unreadable and no arg reports "18 URLs, 0 carrying a
lastmod" and warns.
2. THE DOCKER BUILD CONTEXT CARRIED CLIENT MATERIAL AND LIVE SECRETS. .drop/,
zoho.md (the reCAPTCHA secret and the Zoho tokens), Levi.md and two 30 MB
zips were all sent to the daemon on every build, along with four agent
workspaces. The final image copies only built output, so none of it ever
shipped, but one careless COPY would have changed that. Proven by listing the
context from inside a throwaway image: before, all of it; after, none of it.
3. UNTRACKED FILES PASSED SILENTLY. docker build packs the working tree, so an
untracked module the code imports produces an image that works and a tag that
cannot rebuild it. release.sh now refuses while untracked files are present,
and pre-commit's note counts them too.
#223 also claimed post-commit hides a refused push. It does not: it printed
"push was refused. The commit is safe locally and the branch is now ahead."
during this batch. The issue was corrected on the tracker rather than acted on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:58:37 -05:00
|
|
|
say "verifying the image's sitemap carries dates…"
|
|
|
|
|
|
|
|
|
|
# Ask the artifact, not the wiring. A missing build arg, a typo in the
|
|
|
|
|
# Dockerfile's ARG/ENV pair, or a .dockerignore change would each leave the
|
|
|
|
|
# sitemap undated while the build still succeeds.
|
|
|
|
|
dated=$(docker run --rm --entrypoint sh "${IMAGE}:${TAG}" -c \
|
|
|
|
|
"grep -c '<lastmod>' /app/dist/sitemap.xml" 2>/dev/null | tr -d '\r\n')
|
|
|
|
|
|
|
|
|
|
if [ "${dated:-0}" -lt 1 ]; then
|
|
|
|
|
docker rmi "${IMAGE}:${TAG}" >/dev/null 2>&1
|
|
|
|
|
say "the image's sitemap carries no lastmod at all. Search engines schedule"
|
|
|
|
|
say " recrawls on it, and Bing's index feeds Copilot and ChatGPT search."
|
|
|
|
|
say " Check ARG/ENV SITEMAP_LASTMOD in the builder stage of the"
|
|
|
|
|
say " Dockerfile. Nothing was published or committed; the local image"
|
|
|
|
|
say " was removed."
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
say " ${dated} URL(s) dated."
|
|
|
|
|
|
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 "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."
|