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
|
|
|
|
|
#
|
|
|
|
|
# Move the running stack to an image that is already published.
|
|
|
|
|
#
|
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
|
|
|
# bash scripts/deploy.sh # newest published vX.Y.Z
|
|
|
|
|
# bash scripts/deploy.sh --tag v0.9.4 # a specific published version
|
|
|
|
|
# bash scripts/deploy.sh --allow-floating --tag dev # chase a pointer, loudly
|
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
|
|
|
# bash scripts/deploy.sh --dry-run # show the change, touch nothing
|
|
|
|
|
# bash scripts/deploy.sh --no-backup # skip the pre-deploy dump (say why)
|
|
|
|
|
#
|
|
|
|
|
# Exit codes: 0 deployed and healthy. 1 something failed and the message says
|
|
|
|
|
# what state it left behind. 2 nothing was attempted.
|
|
|
|
|
#
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# THIS DEPLOYS. IT DOES NOT BUILD.
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
#
|
|
|
|
|
# `scripts/release.sh` publishes an image. This points the stack at one. They
|
|
|
|
|
# are separate decisions and separate commands, which is the rule both the
|
|
|
|
|
# template and PrivacyLLC-Web's release script state and the reason neither of
|
|
|
|
|
# them deploys.
|
|
|
|
|
#
|
|
|
|
|
# The template ships `deploy.py`, which does build, push AND deploy. It was not
|
|
|
|
|
# adopted here for exactly that reason: with `release.sh` already publishing a
|
|
|
|
|
# versioned image, a deploy tool that rebuilds would produce a second, different
|
|
|
|
|
# image for the same code and two answers to "what is running". This does the
|
|
|
|
|
# half that was missing and nothing else.
|
|
|
|
|
#
|
|
|
|
|
# ## What it costs, stated up front
|
|
|
|
|
#
|
|
|
|
|
# Redeploying stack 58 RECREATES the container. Both public front doors —
|
|
|
|
|
# queuenorth.com through nginx-proxy-manager, qn.isnull.dev through Cloudflare —
|
|
|
|
|
# reach the same instance, so they go down together for the few seconds it takes.
|
|
|
|
|
# There is no non-production environment to rehearse against.
|
|
|
|
|
#
|
|
|
|
|
# So it takes a verified backup first. The database is a named volume and a
|
|
|
|
|
# stack update does not remove one, but "does not normally" is not a reason to
|
|
|
|
|
# skip the cheap thing before the irreversible one.
|
|
|
|
|
#
|
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
|
|
|
# ## THE POLICY: production always runs a numbered 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
|
|
|
#
|
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
|
|
|
# Set 2026-08-18. The stack pins `:vX.Y.Z` and never a floating 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
|
|
|
#
|
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
|
|
|
# `:dev` and `:latest` are pointers. A stack that follows one has no answer to
|
|
|
|
|
# "what is running" that survives the next push — the tag in the stack file says
|
|
|
|
|
# `dev` today and will still say `dev` after the image underneath it changes, so
|
|
|
|
|
# the file records a preference rather than a fact. Rollback is worse: there is
|
|
|
|
|
# nothing to roll back *to*, because the pointer only ever names now.
|
|
|
|
|
#
|
|
|
|
|
# So this refuses a floating tag. `--allow-floating` exists for the one case
|
|
|
|
|
# that is not a mistake — reproducing a fault on whatever `:dev` happens to be —
|
|
|
|
|
# and it says so loudly every time.
|
|
|
|
|
#
|
|
|
|
|
# **Stack 58 pins `:dev` as this is written.** Correcting that is the first
|
|
|
|
|
# deploy made under this policy, and it is why the default below resolves a
|
|
|
|
|
# version rather than reusing whatever the stack already names.
|
|
|
|
|
#
|
|
|
|
|
# The digest is reported before and after either way, because that is the only
|
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
|
|
|
# thing that cannot be moved underneath you.
|
|
|
|
|
|
|
|
|
|
set -uo pipefail
|
|
|
|
|
|
|
|
|
|
cd "$(git rev-parse --show-toplevel)" || exit 1
|
|
|
|
|
|
|
|
|
|
IMAGE="${DEPLOY_IMAGE:-dream.scheller.ltd/null/queue-north-website}"
|
|
|
|
|
STACK_ID="${DEPLOY_STACK_ID:-58}"
|
|
|
|
|
ENDPOINT_ID="${DEPLOY_ENDPOINT_ID:-3}"
|
|
|
|
|
CONTAINER="${DEPLOY_CONTAINER:-qn-website-dev}"
|
|
|
|
|
HOST="${DEPLOY_HOST:-nebula}"
|
|
|
|
|
PORTAINER_URL="${PORTAINER_API_URL:-https://192.168.1.11:9443}"
|
|
|
|
|
PORTAINER_CREDS="${PORTAINER_CREDS_FILE:-$HOME/.openclaw/credentials/portainer.md}"
|
|
|
|
|
ORIGINS=("${DEPLOY_ORIGIN_PRIMARY:-https://queuenorth.com}" "${DEPLOY_ORIGIN_SECONDARY:-https://qn.isnull.dev}")
|
|
|
|
|
|
|
|
|
|
say() { printf '\033[1mdeploy:\033[0m %s\n' "$*" >&2; }
|
|
|
|
|
die() { printf '\033[1mdeploy:\033[0m %s\n' "$*" >&2; exit 1; }
|
|
|
|
|
stop() { printf '\033[1mdeploy:\033[0m %s\n' "$*" >&2; exit 2; }
|
|
|
|
|
|
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
|
|
|
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
|
|
|
DRY_RUN=""
|
|
|
|
|
NO_BACKUP=""
|
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
|
|
|
ALLOW_FLOATING=""
|
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
|
|
|
ROLLBACK=""
|
|
|
|
|
FIX_CORS=""
|
|
|
|
|
WATCHTOWER_OFF=""
|
|
|
|
|
NO_ROLLBACK=""
|
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
|
|
|
|
|
|
|
|
while [ "$#" -gt 0 ]; do
|
|
|
|
|
case "$1" in
|
|
|
|
|
--tag) shift; [ "$#" -gt 0 ] || stop "--tag needs a value."; TAG="$1" ;;
|
|
|
|
|
--tag=*) TAG="${1#--tag=}" ;;
|
|
|
|
|
--dry-run) DRY_RUN="yes" ;;
|
|
|
|
|
--no-backup) NO_BACKUP="yes" ;;
|
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
|
|
|
--allow-floating) ALLOW_FLOATING="yes" ;;
|
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
|
|
|
--rollback) ROLLBACK="yes" ;;
|
|
|
|
|
--fix-cors) FIX_CORS="yes" ;;
|
|
|
|
|
--watchtower-off) WATCHTOWER_OFF="yes" ;;
|
|
|
|
|
--no-rollback) NO_ROLLBACK="yes" ;;
|
|
|
|
|
-h|--help) say "usage: bash scripts/deploy.sh [--tag vX.Y.Z] [--dry-run] [--no-backup]"
|
|
|
|
|
say " [--fix-cors] [--watchtower-off] [--rollback] [--allow-floating] [--no-rollback]"; exit 0 ;;
|
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
|
|
|
*) stop "unknown argument '$1'. Run --help." ;;
|
|
|
|
|
esac
|
|
|
|
|
shift
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
for t in curl python3; do
|
|
|
|
|
command -v "$t" >/dev/null 2>&1 || stop "$t is not on PATH. Nothing was attempted."
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
KEY="${PORTAINER_API_KEY:-}"
|
|
|
|
|
if [ -z "$KEY" ] && [ -r "$PORTAINER_CREDS" ]; then
|
|
|
|
|
KEY=$(grep -m1 'API Key' "$PORTAINER_CREDS" | grep -oE 'ptr_[^`]+')
|
|
|
|
|
fi
|
|
|
|
|
[ -n "$KEY" ] || stop "no Portainer API key. Set PORTAINER_API_KEY, or put it in
|
|
|
|
|
$PORTAINER_CREDS — the file named after Portainer, not the one named after
|
|
|
|
|
this project. Nothing was attempted."
|
|
|
|
|
|
|
|
|
|
api() { curl -sk --max-time 30 -H "X-API-Key: $KEY" "$@"; }
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Refuse to deploy a tag that is not published. Portainer would accept the
|
|
|
|
|
# stack file, fail to pull, and leave the container stopped — an outage caused
|
|
|
|
|
# by a typo, discovered by the site going down.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
REG_ENV="${RELEASE_REGISTRY_ENV:-$HOME/.openclaw/docker-registry.env}"
|
|
|
|
|
if [ -r "$REG_ENV" ]; then set -a; . "$REG_ENV"; set +a; fi
|
|
|
|
|
|
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
|
|
|
tags_json=""
|
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 [ -n "${FORGEJO_REGISTRY:-}" ] && [ -n "${FORGEJO_REGISTRY_TOKEN:-}" ]; then
|
|
|
|
|
repo="${IMAGE#*/}"
|
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
|
|
|
tags_json=$(curl -sS --max-time 20 -u "${FORGEJO_REGISTRY_USER}:${FORGEJO_REGISTRY_TOKEN}" \
|
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
|
|
|
"https://${FORGEJO_REGISTRY}/v2/${repo}/tags/list" 2>/dev/null)
|
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
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Resolve the tag. With none named, this is the newest published NUMBERED
|
|
|
|
|
# version — never `:dev`, and never "whatever the stack already says", because
|
|
|
|
|
# both of those are how a deployment stops being able to name itself.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
if [ -z "$TAG" ]; then
|
|
|
|
|
[ -n "$tags_json" ] || stop "no tag given and the registry tag list could not be read, so
|
|
|
|
|
the newest published version is unknown. Name one explicitly:
|
|
|
|
|
bash scripts/deploy.sh --tag vX.Y.Z
|
|
|
|
|
Nothing was attempted."
|
|
|
|
|
|
|
|
|
|
TAG=$(printf '%s' "$tags_json" | python3 -c "
|
|
|
|
|
import sys, json, re
|
|
|
|
|
tags = json.load(sys.stdin).get('tags') or []
|
|
|
|
|
def key(t):
|
|
|
|
|
m = re.fullmatch(r'v(\d+)\.(\d+)\.(\d+)', t)
|
|
|
|
|
return tuple(int(x) for x in m.groups()) if m else None
|
|
|
|
|
vers = sorted([t for t in tags if key(t)], key=key)
|
|
|
|
|
print(vers[-1] if vers else '')
|
|
|
|
|
")
|
|
|
|
|
[ -n "$TAG" ] || stop "the registry holds no numbered vX.Y.Z tag to deploy — only
|
|
|
|
|
floating ones. Publish a version first: npm run release. Nothing was attempted."
|
|
|
|
|
say "no tag given; newest published version is ${TAG}"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Refuse a floating tag. See THE POLICY at the top.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
if ! printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
|
|
|
|
if [ -z "$ALLOW_FLOATING" ]; then
|
|
|
|
|
die "'${TAG}' is not a numbered version, and production always runs one.
|
|
|
|
|
A stack pinned to a moving tag cannot say what it is running and cannot be
|
|
|
|
|
rolled back to anything, because the pointer only ever names now.
|
|
|
|
|
Deploy a version: bash scripts/deploy.sh --tag vX.Y.Z
|
|
|
|
|
Publish one first: npm run release
|
|
|
|
|
If you genuinely mean to chase a pointer, --allow-floating."
|
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
|
|
|
fi
|
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 "WARNING: --allow-floating. Deploying the moving tag '${TAG}'."
|
|
|
|
|
say " After this, the stack file will not record what is running."
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Refuse to deploy a tag that is not published. Portainer would accept the
|
|
|
|
|
# stack file, fail to pull, and leave the container stopped — an outage caused
|
|
|
|
|
# by a typo, discovered by the site going down.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
if [ -n "$tags_json" ]; then
|
|
|
|
|
printf '%s' "$tags_json" | grep -q "\"${TAG}\"" \
|
|
|
|
|
|| die "${IMAGE}:${TAG} is not published. Nothing was changed.
|
|
|
|
|
Published tags: $(printf '%s' "$tags_json" | python3 -c 'import sys,json;print(", ".join(json.load(sys.stdin).get("tags") or []))' 2>/dev/null)"
|
|
|
|
|
else
|
|
|
|
|
say "WARNING: could not read the registry tag list, so it is not known whether"
|
|
|
|
|
say " ${TAG} is published. A missing tag becomes an outage here."
|
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
|
|
|
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
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Refuse to go backwards.
|
|
|
|
|
#
|
|
|
|
|
# The newest published NUMBERED tag is not necessarily newer than what is
|
|
|
|
|
# running: on 2026-08-18 it was v0.8.3, built 2026-05-28, while the running
|
|
|
|
|
# :dev image was built 2026-08-01. Deploying it would have removed the privacy
|
|
|
|
|
# policy and every prerendered route — two months of regressions, applied by
|
|
|
|
|
# the default path with no warning.
|
|
|
|
|
#
|
|
|
|
|
# So compare creation dates. The target's is read from the registry WITHOUT
|
|
|
|
|
# pulling it: manifest -> config digest -> config blob -> .created.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
registry_created() {
|
|
|
|
|
local tag="$1" tok cfg
|
|
|
|
|
tok=$(curl -sS --max-time 20 -u "${FORGEJO_REGISTRY_USER:-}:${FORGEJO_REGISTRY_TOKEN:-}" \
|
|
|
|
|
"https://${FORGEJO_REGISTRY}/v2/token?scope=repository:${IMAGE#*/}:pull&service=container_registry" \
|
|
|
|
|
2>/dev/null | python3 -c 'import sys,json;print(json.load(sys.stdin).get("token",""))' 2>/dev/null)
|
|
|
|
|
[ -n "$tok" ] || return 1
|
|
|
|
|
cfg=$(curl -sS --max-time 20 -H "Authorization: Bearer $tok" \
|
|
|
|
|
-H 'Accept: application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json' \
|
|
|
|
|
"https://${FORGEJO_REGISTRY}/v2/${IMAGE#*/}/manifests/${tag}" 2>/dev/null \
|
|
|
|
|
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("config",{}).get("digest",""))' 2>/dev/null)
|
|
|
|
|
[ -n "$cfg" ] || return 1
|
|
|
|
|
curl -sSL --max-time 30 -H "Authorization: Bearer $tok" \
|
|
|
|
|
"https://${FORGEJO_REGISTRY}/v2/${IMAGE#*/}/blobs/${cfg}" 2>/dev/null \
|
|
|
|
|
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("created",""))' 2>/dev/null
|
|
|
|
|
}
|
|
|
|
|
|
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 is running now. Reported before anything changes, so the two halves of
|
|
|
|
|
# "before and after" come from the same run.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
before_digest=$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" \
|
|
|
|
|
"docker image inspect \$(docker inspect '$CONTAINER' --format '{{.Image}}') --format '{{index .RepoDigests 0}}'" 2>/dev/null)
|
|
|
|
|
before_version=$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" \
|
|
|
|
|
"docker inspect '$CONTAINER' --format '{{index .Config.Labels \"org.opencontainers.image.version\"}}'" 2>/dev/null)
|
|
|
|
|
|
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
|
|
|
target_created=$(registry_created "$TAG")
|
|
|
|
|
running_created=$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" \
|
|
|
|
|
"docker image inspect \$(docker inspect '$CONTAINER' --format '{{.Image}}') --format '{{.Created}}'" 2>/dev/null)
|
|
|
|
|
|
|
|
|
|
if [ -n "$target_created" ] && [ -n "$running_created" ]; then
|
|
|
|
|
if [ "$target_created" \< "$running_created" ] && [ -z "$ROLLBACK" ]; then
|
|
|
|
|
die "${TAG} was built ${target_created}, and what is running was built ${running_created}.
|
|
|
|
|
That is BACKWARDS. Deploying it would undo everything built in between —
|
|
|
|
|
on this project that has meant losing the privacy policy and every
|
|
|
|
|
prerendered route, silently, on the default path.
|
|
|
|
|
If you mean to roll back, say so: --rollback
|
|
|
|
|
Nothing was changed."
|
|
|
|
|
fi
|
|
|
|
|
[ -n "$ROLLBACK" ] && say "--rollback: deploying an image built ${target_created}"
|
|
|
|
|
else
|
|
|
|
|
say "WARNING: could not compare build dates (target='${target_created:-?}'"
|
|
|
|
|
say " running='${running_created:-?}'), so the going-backwards check"
|
|
|
|
|
say " did not run. That is not a pass."
|
|
|
|
|
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 "stack ${STACK_ID} on ${HOST} (container ${CONTAINER})"
|
|
|
|
|
say "deploying ${IMAGE}:${TAG}"
|
|
|
|
|
say "running ${before_digest:-unknown}"
|
|
|
|
|
say "version ${before_version:-<no version label on the running image>}"
|
|
|
|
|
|
|
|
|
|
stack=$(api "${PORTAINER_URL}/api/stacks/${STACK_ID}") \
|
|
|
|
|
|| die "could not reach Portainer at ${PORTAINER_URL}. Nothing was changed."
|
|
|
|
|
printf '%s' "$stack" | grep -q '"Id"' \
|
|
|
|
|
|| die "Portainer did not return stack ${STACK_ID}. Nothing was changed."
|
|
|
|
|
|
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
|
|
|
# Written to a file rather than captured in $( ), which strips trailing
|
|
|
|
|
# newlines. The first version of this did, so every PUT silently removed the
|
|
|
|
|
# file's final newline — harmless to YAML, and still a change beyond the lines
|
|
|
|
|
# this script says it touches. "Only the image line" has to be true.
|
|
|
|
|
STACK_ORIG="$(mktemp)"; STACK_NEW="$(mktemp)"
|
|
|
|
|
trap 'rm -f "$STACK_ORIG" "$STACK_NEW"' EXIT
|
|
|
|
|
api "${PORTAINER_URL}/api/stacks/${STACK_ID}/file" \
|
|
|
|
|
| python3 -c 'import sys,json;sys.stdout.write(json.load(sys.stdin)["StackFileContent"])' > "$STACK_ORIG" \
|
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
|
|
|
|| die "could not read the stack file. Nothing was changed."
|
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
|
|
|
[ -s "$STACK_ORIG" ] || die "the stack file came back empty. Nothing was changed."
|
|
|
|
|
file=$(cat "$STACK_ORIG")
|
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
|
|
|
|
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
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Build the new stack file from the original bytes. Every edit is opt-in and
|
|
|
|
|
# every one is printed as a diff before anything is sent.
|
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
|
|
|
#
|
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
|
|
|
# python3 with the script as an ARGUMENT and the file on stdin: a heredoc would
|
|
|
|
|
# claim stdin too. The first version of this did exactly that, and python tried
|
|
|
|
|
# to execute the YAML.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
python3 -c "
|
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
|
|
|
import re, sys
|
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
|
|
|
img, tag, fix_cors, wt_off = sys.argv[1], sys.argv[2], sys.argv[3] == 'yes', sys.argv[4] == 'yes'
|
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
|
|
|
s = sys.stdin.read()
|
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
|
|
|
|
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
|
|
|
pat = re.compile(r'^(\s*image:\s*)' + re.escape(img) + r':\S+[ \t]*\$', re.M)
|
|
|
|
|
if not pat.search(s):
|
|
|
|
|
sys.exit(3)
|
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
|
|
|
s = pat.sub(lambda m: m.group(1) + img + ':' + tag, s)
|
|
|
|
|
|
|
|
|
|
if fix_cors:
|
|
|
|
|
c = re.compile(r'^(\s*-\s*CORS_ORIGIN=)(\S+?)/+[ \t]*\$', re.M)
|
|
|
|
|
if not c.search(s):
|
|
|
|
|
sys.stderr.write('NOCORS\n')
|
|
|
|
|
sys.exit(4)
|
|
|
|
|
s = c.sub(lambda m: m.group(1) + m.group(2), s)
|
|
|
|
|
|
|
|
|
|
if wt_off:
|
|
|
|
|
lab = 'com.centurylinklabs.watchtower.enable=false'
|
|
|
|
|
if lab not in s:
|
|
|
|
|
m = re.search(r'^(\s*)restart:\s*\S+[ \t]*\$', s, re.M)
|
|
|
|
|
if not m:
|
|
|
|
|
sys.stderr.write('NOANCHOR\n')
|
|
|
|
|
sys.exit(5)
|
|
|
|
|
ind = m.group(1)
|
|
|
|
|
block = f'{ind}labels:\n{ind} - "{lab}"\n'
|
|
|
|
|
s = s[:m.start()] + block + s[m.start():]
|
|
|
|
|
|
|
|
|
|
sys.stdout.write(s)
|
|
|
|
|
" "$IMAGE" "$TAG" "${FIX_CORS:-no}" "${WATCHTOWER_OFF:-no}" < "$STACK_ORIG" > "$STACK_NEW"
|
|
|
|
|
rc=$?
|
|
|
|
|
case "$rc" in
|
|
|
|
|
0) ;;
|
|
|
|
|
3) die "the stack file has no 'image: ${IMAGE}:<tag>' line to update. Nothing was changed." ;;
|
|
|
|
|
4) die "--fix-cors: no CORS_ORIGIN line with a trailing slash to fix. Either it is
|
|
|
|
|
already correct or the line moved. Nothing was changed." ;;
|
|
|
|
|
5) die "--watchtower-off: could not find a 'restart:' line to anchor the labels
|
|
|
|
|
block to. Nothing was changed." ;;
|
|
|
|
|
*) die "could not build the new stack file (exit $rc). Nothing was changed." ;;
|
|
|
|
|
esac
|
|
|
|
|
|
|
|
|
|
[ -s "$STACK_NEW" ] || die "the new stack file came out empty. Nothing was changed."
|
|
|
|
|
|
|
|
|
|
# Every env line must survive. A dropped one comes back healthy and stops
|
|
|
|
|
# capturing leads, which is the worst shape of failure available here.
|
|
|
|
|
orig_env=$(grep -cE '^\s*-\s*[A-Z_]+=' "$STACK_ORIG")
|
|
|
|
|
new_env=$(grep -cE '^\s*-\s*[A-Z_]+=' "$STACK_NEW")
|
|
|
|
|
[ "$orig_env" -eq "$new_env" ] || die "the rewrite changed the number of environment lines
|
|
|
|
|
(${orig_env} -> ${new_env}). Refusing to send it. Nothing was 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
|
|
|
|
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 ""
|
|
|
|
|
say "the exact change to the stack file:"
|
|
|
|
|
diff -u "$STACK_ORIG" "$STACK_NEW" \
|
|
|
|
|
| sed -E 's/^([-+].*(SECRET|XNQSJSDP|XMIWTLD)=).+/\1<redacted>/' \
|
|
|
|
|
| sed 's/^/ /' >&2
|
|
|
|
|
say ""
|
|
|
|
|
|
|
|
|
|
new_file=$(cat "$STACK_NEW")
|
|
|
|
|
|
|
|
|
|
if cmp -s "$STACK_ORIG" "$STACK_NEW"; then
|
|
|
|
|
say "the stack file is already exactly as requested — redeploying it to pull the newest digest."
|
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
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ -n "$DRY_RUN" ]; then
|
|
|
|
|
say ""
|
|
|
|
|
say "--dry-run: nothing was changed. It would have:"
|
|
|
|
|
[ -z "$NO_BACKUP" ] && say " bash scripts/backup.sh (verified dump, before the irreversible part)"
|
|
|
|
|
say " PUT ${PORTAINER_URL}/api/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID} (pullImage: true)"
|
|
|
|
|
say " waited for ${CONTAINER} to report healthy on ${HOST}"
|
|
|
|
|
say " checked ${ORIGINS[*]}"
|
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 " checked the live bundle still carries the reCAPTCHA site key"
|
|
|
|
|
say " rolled the whole stack file back automatically if any of that failed"
|
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 "That PUT recreates the container. Both front doors go down together."
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Backup before the irreversible part. backup.sh verifies its own dump.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
if [ -z "$NO_BACKUP" ]; then
|
|
|
|
|
say "taking a verified backup first…"
|
|
|
|
|
bash scripts/backup.sh >&2 \
|
|
|
|
|
|| die "the backup failed, so NOTHING was deployed. Fix it, or re-run with
|
|
|
|
|
--no-backup if you have a copy elsewhere and can say where."
|
|
|
|
|
else
|
|
|
|
|
say "WARNING: --no-backup. Deploying without a pre-deploy dump of the only copy"
|
|
|
|
|
say " of every lead this site has taken."
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# The irreversible part.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
say "redeploying stack ${STACK_ID} — the container is being recreated…"
|
|
|
|
|
|
|
|
|
|
# The stack's existing Env array must be sent back with the update. Portainer
|
|
|
|
|
# treats a PUT as the whole desired state, so omitting it would silently strip
|
|
|
|
|
# twelve variables — including the reCAPTCHA secret and the Zoho form tokens —
|
|
|
|
|
# and the container would come up healthy and quietly stop capturing leads.
|
|
|
|
|
payload=$(printf '%s' "$stack" | python3 -c "
|
|
|
|
|
import sys, json
|
|
|
|
|
stack = json.load(sys.stdin)
|
|
|
|
|
env = stack.get('Env') or []
|
|
|
|
|
if not env:
|
|
|
|
|
sys.stderr.write('NOENV\\n')
|
|
|
|
|
sys.exit(3)
|
|
|
|
|
print(json.dumps({
|
|
|
|
|
'stackFileContent': sys.argv[1],
|
|
|
|
|
'env': env,
|
|
|
|
|
'prune': False,
|
|
|
|
|
'pullImage': True,
|
|
|
|
|
}))
|
|
|
|
|
" "$new_file") || die "could not build the update payload, or the stack reported no
|
|
|
|
|
environment variables at all. Sending an empty env would strip the Zoho and
|
|
|
|
|
reCAPTCHA credentials from the running container. Nothing was changed."
|
|
|
|
|
|
|
|
|
|
env_count=$(printf '%s' "$payload" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)["env"]))' 2>/dev/null)
|
|
|
|
|
say "preserving ${env_count:-?} stack environment variables"
|
|
|
|
|
|
|
|
|
|
resp=$(api -X PUT -H "Content-Type: application/json" --data-binary "$payload" \
|
|
|
|
|
"${PORTAINER_URL}/api/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}")
|
|
|
|
|
|
|
|
|
|
printf '%s' "$resp" | grep -q '"Id"' || die "the redeploy was refused by Portainer:
|
|
|
|
|
$(printf '%s' "$resp" | head -c 400)
|
|
|
|
|
The stack may or may not have changed — check it before retrying."
|
|
|
|
|
|
|
|
|
|
say "stack updated. waiting for ${CONTAINER}…"
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
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
|
|
|
# Verify, and undo it if the verification fails.
|
|
|
|
|
#
|
|
|
|
|
# Three things are checked, and the third is the one that would otherwise be
|
|
|
|
|
# found by a customer: the reCAPTCHA site key is frozen into the bundle at
|
|
|
|
|
# build time, and an image built without it renders "Security verification is
|
|
|
|
|
# not configured." and rejects every submission. Health checks pass happily
|
|
|
|
|
# while that is true, so the live bundle is asked directly.
|
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
|
|
|
# ---------------------------------------------------------------------------
|
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
|
|
|
EXPECTED_KEY=$(printf '%s' "$stack" | python3 -c "
|
|
|
|
|
import sys, json
|
|
|
|
|
env = {e['name']: e['value'] for e in (json.load(sys.stdin).get('Env') or [])}
|
|
|
|
|
print(env.get('VITE_RECAPTCHA_SITE_KEY', ''))
|
|
|
|
|
" 2>/dev/null)
|
|
|
|
|
|
|
|
|
|
wait_healthy() {
|
|
|
|
|
local i state
|
|
|
|
|
for i in $(seq 1 30); do
|
|
|
|
|
state=$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" \
|
|
|
|
|
"docker inspect '$CONTAINER' --format '{{.State.Status}}:{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}'" 2>/dev/null)
|
|
|
|
|
case "$state" in running:healthy|running:none) return 0 ;; esac
|
|
|
|
|
sleep 4
|
|
|
|
|
done
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
check_live() {
|
|
|
|
|
local fail=0 o body js
|
|
|
|
|
for o in "${ORIGINS[@]}"; do
|
|
|
|
|
body=$(curl -s --max-time 15 "$o/api/health")
|
|
|
|
|
if printf '%s' "$body" | grep -q '"status":"ok"' && printf '%s' "$body" | grep -q '"db":"ok"'; then
|
|
|
|
|
say " ok $o"
|
|
|
|
|
else
|
|
|
|
|
say " FAIL $o -> ${body:-<no response>}"; fail=1
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
if [ -n "$EXPECTED_KEY" ]; then
|
|
|
|
|
js=$(curl -s --max-time 15 "${ORIGINS[0]}/" | grep -oE 'assets/index-[A-Za-z0-9_-]+\.js' | head -1)
|
|
|
|
|
if [ -z "$js" ]; then
|
|
|
|
|
say " FAIL could not find the bundle on ${ORIGINS[0]}"; fail=1
|
|
|
|
|
elif curl -s --max-time 20 "${ORIGINS[0]}/${js}" | grep -qF "$EXPECTED_KEY"; then
|
|
|
|
|
say " ok the live bundle carries the reCAPTCHA site key"
|
|
|
|
|
else
|
|
|
|
|
say " FAIL the live bundle does NOT carry the reCAPTCHA site key."
|
|
|
|
|
say " The contact form will read \"Security verification is not"
|
|
|
|
|
say " configured.\" and reject every submission."
|
|
|
|
|
fail=1
|
|
|
|
|
fi
|
|
|
|
|
else
|
|
|
|
|
say " note the stack declares no VITE_RECAPTCHA_SITE_KEY, so the bundle"
|
|
|
|
|
say " check was skipped. That is not a pass."
|
|
|
|
|
fi
|
|
|
|
|
return $fail
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deploy_failed=""
|
|
|
|
|
wait_healthy || deploy_failed="the container did not come back healthy"
|
|
|
|
|
if [ -z "$deploy_failed" ]; then
|
|
|
|
|
say "checking the live site…"
|
|
|
|
|
check_live || deploy_failed="the container is healthy but the live checks failed"
|
|
|
|
|
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
|
|
|
|
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
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Auto-rollback. Once, and only once.
|
|
|
|
|
#
|
|
|
|
|
# The original bytes are still in $STACK_ORIG, so this restores the state that
|
|
|
|
|
# was known to work — not just the image line, but the CORS and label edits
|
|
|
|
|
# too. A half-applied change is not a rollback.
|
|
|
|
|
#
|
|
|
|
|
# The previous image is already on the host, so this does not depend on the
|
|
|
|
|
# registry being reachable at the worst possible moment.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
if [ -n "$deploy_failed" ]; then
|
|
|
|
|
say ""
|
|
|
|
|
say "DEPLOY FAILED: ${deploy_failed}."
|
|
|
|
|
|
|
|
|
|
if [ -n "$NO_ROLLBACK" ]; then
|
|
|
|
|
die "--no-rollback: leaving it exactly as it is so you can inspect it.
|
|
|
|
|
Logs: bash scripts/status.sh --logs 200
|
|
|
|
|
Restore: bash scripts/deploy.sh --tag <previous> --rollback
|
|
|
|
|
Backup: \$HOME/backups/queue-north-website"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
say "rolling back to the previous stack file…"
|
|
|
|
|
rb_payload=$(printf '%s' "$stack" | python3 -c "
|
|
|
|
|
import sys, json
|
|
|
|
|
stack = json.load(sys.stdin)
|
|
|
|
|
print(json.dumps({'stackFileContent': sys.argv[1], 'env': stack.get('Env') or [],
|
|
|
|
|
'prune': False, 'pullImage': True}))
|
|
|
|
|
" "$(cat "$STACK_ORIG")")
|
|
|
|
|
|
|
|
|
|
if api -X PUT -H "Content-Type: application/json" --data-binary "$rb_payload" \
|
|
|
|
|
"${PORTAINER_URL}/api/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" | grep -q '"Id"'; then
|
|
|
|
|
if wait_healthy && check_live; then
|
|
|
|
|
say ""
|
|
|
|
|
die "ROLLED BACK successfully. The site is serving the previous image again.
|
|
|
|
|
The deploy of ${TAG} failed: ${deploy_failed}.
|
|
|
|
|
Nothing is lost; the pre-deploy backup is in \$HOME/backups/queue-north-website."
|
|
|
|
|
fi
|
|
|
|
|
die "THE ROLLBACK ALSO FAILED. THE SITE IS LIKELY DOWN. Stopping rather than
|
|
|
|
|
trying again — a script retrying an outage is how a short one becomes long.
|
|
|
|
|
bash scripts/status.sh --logs 200
|
|
|
|
|
Portainer: ${PORTAINER_URL} stack ${STACK_ID}"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
die "THE ROLLBACK PUT WAS REFUSED. THE SITE IS LIKELY DOWN.
|
|
|
|
|
bash scripts/status.sh --logs 200
|
|
|
|
|
Portainer: ${PORTAINER_URL} stack ${STACK_ID}"
|
|
|
|
|
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
|
|
|
|
|
|
|
|
after_digest=$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" \
|
|
|
|
|
"docker image inspect \$(docker inspect '$CONTAINER' --format '{{.Image}}') --format '{{index .RepoDigests 0}}'" 2>/dev/null)
|
|
|
|
|
after_version=$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" \
|
|
|
|
|
"docker inspect '$CONTAINER' --format '{{index .Config.Labels \"org.opencontainers.image.version\"}}'" 2>/dev/null)
|
|
|
|
|
|
|
|
|
|
say ""
|
|
|
|
|
say "digest ${before_digest:-unknown}"
|
|
|
|
|
say " -> ${after_digest:-unknown}"
|
|
|
|
|
say "version ${before_version:-none} -> ${after_version:-none}"
|
|
|
|
|
|
|
|
|
|
say ""
|
|
|
|
|
say "deployed. Record it: docs/history/DEVELOPMENT_LOG.md, and close whatever"
|
|
|
|
|
say "issue this shipped with the evidence above."
|