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>
This commit is contained in:
parent
e3c360008e
commit
2ae7352799
|
|
@ -42,8 +42,10 @@ from here — goes unanswered.
|
|||
### Two front doors, one container
|
||||
|
||||
**Both hostnames are this application, and there is only one of it.** Verified
|
||||
2026-08-18: both serve the identical bundle (`assets/index-pTFwovIx.js`) and both
|
||||
answer `/api/health` with this server's exact `{"status":"ok","db":"ok"}` shape.
|
||||
2026-08-18: both served byte-identical bundle filenames — index-pTFwovIx.js at
|
||||
the time, named without backticks because it is a build artefact whose hash
|
||||
changes with every build and never a path in this repository — and both answer
|
||||
`/api/health` with this server's exact `{"status":"ok","db":"ok"}` shape.
|
||||
|
||||
```text
|
||||
queuenorth.com ──► 24.41.108.95 (this network's own public IP)
|
||||
|
|
@ -214,15 +216,18 @@ change anything.
|
|||
Both failing while `scripts/status.sh` says healthy points at the LAN path or
|
||||
the host itself.
|
||||
|
||||
### Rolling forward or back
|
||||
### Releasing and deploying — two commands, deliberately
|
||||
|
||||
**Publishing and deploying are two acts.** `npm run docker:push` builds the
|
||||
image and pushes `queue-north-website:dev` to the Forgejo registry. That changes
|
||||
nothing about what is running.
|
||||
**Publishing an image and running it are separate decisions.** Nothing about a
|
||||
published image affects the site until a deploy moves the stack to it.
|
||||
|
||||
**Deploying is a Portainer stack redeploy.** Established 2026-08-18 by reading
|
||||
the container's own compose labels, which is why it is written down here rather
|
||||
than re-derived:
|
||||
```bash
|
||||
npm run release # publish: patch bump, guards, build, push, tag
|
||||
npm run deploy # deploy: move stack 58 to what :dev now points at
|
||||
```
|
||||
|
||||
Both take `--dry-run`, and both refuse rather than guess. Run the dry runs first;
|
||||
they print exactly what would change.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
|
|
@ -231,36 +236,44 @@ than re-derived:
|
|||
| Stack file on disk | `/data/compose/58/docker-compose.yml` on nebula |
|
||||
|
||||
**The stack file is NOT this repository's `docker-compose.yml`.** They are two
|
||||
separate files that happen to look alike, and they have already drifted: the
|
||||
separate files that happen to look alike, and they have already drifted — the
|
||||
repository commits `CORS_ORIGIN=https://queuenorth.com` and line 21 of the stack
|
||||
file says `https://queuenorth.com/`, with a trailing slash. Editing the one in
|
||||
git changes nothing about production. That is the single most important sentence
|
||||
in this section.
|
||||
in this section, and `deploy.sh` does not paper over it: it reads the deployed
|
||||
file, changes only the `image:` line, and sends the rest back untouched.
|
||||
|
||||
Read the deployed file, and the environment the container actually got:
|
||||
**What a deploy costs.** It recreates the container, so both public front doors
|
||||
go down together for the few seconds it takes — they reach the same instance.
|
||||
There is no non-production environment to rehearse against. `deploy.sh` takes a
|
||||
verified backup before the irreversible part for that reason, and refuses to
|
||||
continue if the backup fails.
|
||||
|
||||
**Deploy a version, not the pointer, when it matters.** `npm run deploy` follows
|
||||
`:dev`, which is a moving tag: afterwards nothing in the stack file says which
|
||||
image that was. `npm run deploy -- --tag v0.9.4` pins an immutable version, which
|
||||
is what you want when somebody asks what is running, and what you need to roll
|
||||
back:
|
||||
|
||||
```bash
|
||||
KEY=$(grep -m1 'API Key' ~/.openclaw/credentials/portainer.md | grep -oE 'ptr_[^`]+')
|
||||
curl -sk -H "X-API-Key: $KEY" https://192.168.1.11:9443/api/stacks/58/file
|
||||
bash scripts/status.sh # what is running, and its image digest
|
||||
bash scripts/deploy.sh --tag v0.8.3 --dry-run # see the change first
|
||||
bash scripts/deploy.sh --tag v0.8.3 # roll back to a known image
|
||||
```
|
||||
|
||||
To move to a newly published image, or to change an environment value, redeploy
|
||||
the stack — through the Portainer UI, or its API with `PUT /api/stacks/58`
|
||||
carrying the full stack file and `pullImage: true`.
|
||||
It refuses a tag the registry does not hold — a missing tag would otherwise
|
||||
become an outage, since Portainer accepts the file, fails to pull, and leaves
|
||||
the container stopped.
|
||||
|
||||
**Redeploying recreates the container, which is a restart of the customer's live
|
||||
site.** Both public front doors go down together for the few seconds it takes,
|
||||
because they reach the same instance. There is no non-production environment to
|
||||
rehearse against; `npm run docker:test` running the image locally is the closest
|
||||
thing that exists. Take the backup first — `bash scripts/backup.sh` — because
|
||||
the database is in a named volume that a careless `down -v` would remove.
|
||||
**What `deploy.sh` preserves, and why that is the dangerous part.** Portainer
|
||||
treats a stack `PUT` as the whole desired state. Sending it without the stack's
|
||||
`Env` array would strip twelve variables — including the reCAPTCHA secret and
|
||||
the Zoho form tokens — and the container would come back **healthy** while
|
||||
quietly failing to capture a single lead. The script reads them, counts them,
|
||||
sends them back, and refuses outright if the stack reports none.
|
||||
|
||||
The template's `scripts/deploy.py` is built for exactly this shape and reads
|
||||
`DEPLOY_STACK_ID`, `DEPLOY_IMAGE`, `DEPLOY_CONTAINER` and `DEPLOY_SITE_URL` from
|
||||
the environment. **It was declined on adoption** because none of the above was
|
||||
known then. It is adoptable now, and worth taking the next time a deploy is
|
||||
needed rather than doing it by hand twice more.
|
||||
Afterwards it waits for the container's own healthcheck, then checks
|
||||
`/api/health` on **both** origins, and prints the image digest and version label
|
||||
from before and after.
|
||||
|
||||
**Write the incident down afterwards**, in `docs/history/DEVELOPMENT_LOG.md` with
|
||||
the date, and file what broke as an issue with a severity label. An incident
|
||||
|
|
|
|||
|
|
@ -127,12 +127,20 @@ Run from the repository root.
|
|||
| `npm start` / `npm run server` | the Express server alone, serving `dist/` |
|
||||
| `npm run docker:build` / `docker:run` | build and run the image locally |
|
||||
| `npm run docker:compose:up` / `:down` / `:logs` | the compose stack |
|
||||
| `npm run docker:push` | build, tag and push `queue-north-website:dev` to the Forgejo registry |
|
||||
| `npm run release` | **publish an image.** Bump, guards, build, verify its version label, push `:vX.Y.Z` and move `:dev`, commit last, tag. `-- --dry-run` first |
|
||||
| `npm run deploy` | **move the running stack** to a published image. Backs up first. `-- --dry-run` first, `-- --tag vX.Y.Z` to pin or roll back |
|
||||
| `npm run docker:push` | build, tag and push `queue-north-website:dev` with no bump and no guards. Superseded by `npm run release` |
|
||||
| `npm run docker:test` | build the image and smoke-test it on 3001 |
|
||||
|
||||
**There is no `npm test`, and that is not an omission in this table.** There is
|
||||
no test runner in the project. `docs/qa/ClaudeQACoverage.md` carries it as a
|
||||
standing gap.
|
||||
standing gap — and it is why `npm run release` says out loud that its gate is a
|
||||
build, a secret scan and a doc-header check rather than pretending those are
|
||||
tests.
|
||||
|
||||
**`release` and `deploy` are two commands on purpose.** Publishing an image and
|
||||
running it are separate decisions; see `docs/OPERATIONS.md`. A deploy recreates
|
||||
the container and takes both public front doors down together.
|
||||
|
||||
A liveness check by hand, when you want one without the script:
|
||||
|
||||
|
|
@ -161,8 +169,9 @@ DOC_CLAIMS_EXCLUDE='docs/proposed/|project-template/|vendor/|docs/history/' \
|
|||
|
||||
That exclusion is not a way of quietening a failure. `docs/history/DEVELOPMENT_LOG.md`
|
||||
is dated and append-only, and its entries name files that existed **when they
|
||||
were written** — `src/pages/8x8.jsx`, removed at `0.6.6`; `src/App.css`, gone in
|
||||
a later refactor. Those are receipts, not claims about now, and correcting them
|
||||
were written** — src/pages/8x8.jsx, removed at `0.6.6`; src/App.css, gone in a
|
||||
later refactor. Both are named without backticks here for the same reason the
|
||||
exclusion exists: a backticked path is a claim the file is present. Those are receipts, not claims about now, and correcting them
|
||||
would rewrite the record of what was known at the time, which is the one thing
|
||||
that file is for. Checking a history log for present-tense accuracy is a category
|
||||
error, so it is excluded rather than edited.
|
||||
|
|
|
|||
|
|
@ -150,10 +150,44 @@ each row says what it does *here*.
|
|||
| `scripts/preflight.sh` | headers and TLS against the live origin. No `--auth` checks: there are no accounts |
|
||||
| `scripts/backup.sh` | a verified SQLite dump. Its ENGINE block was rewritten for better-sqlite3's online `.backup()` — see below |
|
||||
| `scripts/restore-check.sh` | restores the newest dump into a scratch file, runs `PRAGMA integrity_check`, counts tables, and **times it**. A backup nobody has restored is a guess |
|
||||
| `scripts/docker-push.sh` | builds and pushes `queue-north-website:dev` to the Forgejo registry. Predates the template |
|
||||
| `scripts/release.sh` | **publishes.** Bump, guard, build, verify the image's own version label, push `:vX.Y.Z` **and** move `:dev`, commit last, tag. Refuses to overwrite a published tag, to build on a dirty tree, or to build when the three hard-coded copies of the public origin disagree. Adapted from PrivacyLLC-Web's — see below |
|
||||
| `scripts/deploy.sh` | **deploys, and does not build.** Points Portainer stack 58 at an already-published tag, takes a verified backup first, preserves the stack's twelve environment variables, then waits for health and checks both public origins. Reports the digest before and after |
|
||||
| `scripts/docker-push.sh` | builds and pushes `queue-north-website:dev` only, with no version bump and no guards. Predates the template and is **superseded by `release.sh`** — kept for now because it is what the muscle memory reaches for |
|
||||
| `scripts/docker-test.sh` | builds the image and runs it locally on 3001. Predates the template |
|
||||
| `scripts/prerender.js` | the build step that emits static HTML for every route. Predates the template |
|
||||
|
||||
**Why `release.sh` and `deploy.sh` are two scripts.** Publishing an image and
|
||||
running it are separate decisions, which is the rule both the template and
|
||||
PrivacyLLC-Web's release script state and the reason neither of them deploys.
|
||||
The template's `deploy.py` does build, push *and* deploy; adopting it beside
|
||||
`release.sh` would have produced two commands that both build, a second image
|
||||
for the same code, and two answers to "what is running". So `deploy.sh` does
|
||||
only the half that was missing.
|
||||
|
||||
Three things about `release.sh` differ from the script it was adapted from, and
|
||||
each is a fact about this project rather than a preference:
|
||||
|
||||
- **It gates on `verify.sh`, not on 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 exercised a route, a form or an API response.
|
||||
- **It moves `:dev` as well as publishing `:vX.Y.Z`,** because stack 58 follows
|
||||
`:dev`. That makes `:dev` a pointer and never evidence — `status.sh` reads the
|
||||
digest and the version label for that.
|
||||
- **It checks the public origin in three files rather than one.** The original
|
||||
passes its origin in as a build arg, so it has one copy to validate. Here
|
||||
`https://queuenorth.com` is written out in `src/lib/seo.js`,
|
||||
`src/components/SEO.jsx` and `scripts/prerender.js`, and it is baked into every
|
||||
canonical URL, `og:url`, `sitemap.xml` and `robots.txt`. The guard asks whether
|
||||
the three still agree, because a wrong origin cannot be corrected without
|
||||
another build and is invisible until somebody reads the page source.
|
||||
|
||||
**Neither script prunes the registry.** The original does, because it releases
|
||||
often enough for that to matter. This project has published thirteen tags in its
|
||||
life, deleting a published image is irreversible, and the one that matters is
|
||||
whichever the running container was created from — exactly what a newest-N rule
|
||||
gets wrong.
|
||||
|
||||
**Why `secrets.sh` has a tightened pattern.** Its `user:pass@host in a URL`
|
||||
rule excludes only `/`, `@`, `:` and whitespace in the template. That is right
|
||||
for source and wrong for this project's build output: every prerendered page
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ running system on this date, and how:
|
|||
| `https://queuenorth.com/` returns 200 | `curl`, once |
|
||||
| `https://qn.isnull.dev/` returns 200 | `curl`, once |
|
||||
| `/api/health` returns `{"status":"ok","db":"ok"}` on **both** origins | `curl`, once each |
|
||||
| Both origins serve the identical bundle `assets/index-pTFwovIx.js` | `curl`, once each — this is what established they are one container, not two |
|
||||
| Both origins serve an identical bundle filename — index-pTFwovIx.js on this date, unbackticked because a content-hashed artefact is not a repository path | `curl`, once each — this is what established they are one container, not two |
|
||||
| `www.queuenorth.com` 301s to the apex; both origins 301 plain HTTP and carry HSTS | `curl -I`, and `scripts/preflight.sh` clean against each |
|
||||
| Container `qn-website-dev` on nebula is healthy, restart count 0 | `docker inspect` |
|
||||
| The lead database holds 3 tables and took writes on 2026-08-18 | `sqlite_master` count and the file mtime |
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
||||
"build": "vite build && vite build --ssr src/entry-server.jsx --outDir dist-ssr && node scripts/prerender.js",
|
||||
"verify": "bash scripts/verify.sh",
|
||||
"release": "bash scripts/release.sh",
|
||||
"deploy": "bash scripts/deploy.sh",
|
||||
"build:client": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node server/index.js",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Move the running stack to an image that is already published.
|
||||
#
|
||||
# bash scripts/deploy.sh # deploy whatever :dev points at
|
||||
# bash scripts/deploy.sh --tag v0.9.4 # pin a specific published version
|
||||
# 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.
|
||||
#
|
||||
# ## Why :dev is the default and a version tag is better
|
||||
#
|
||||
# Stack 58 pins `:dev`, which is a moving pointer. Deploying it means "whatever
|
||||
# was published most recently", and after the fact nothing in the stack file
|
||||
# says which image that was. Passing --tag pins an immutable version instead,
|
||||
# which is what you want the day somebody asks what is running — and the day you
|
||||
# need to roll back to something specific.
|
||||
#
|
||||
# Either way the digest is reported before and after, because that is the only
|
||||
# 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; }
|
||||
|
||||
TAG="${DEPLOY_DEFAULT_TAG:-dev}"
|
||||
DRY_RUN=""
|
||||
NO_BACKUP=""
|
||||
|
||||
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" ;;
|
||||
-h|--help) say "usage: bash scripts/deploy.sh [--tag <tag>] [--dry-run] [--no-backup]"; exit 0 ;;
|
||||
*) 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
|
||||
|
||||
if [ -n "${FORGEJO_REGISTRY:-}" ] && [ -n "${FORGEJO_REGISTRY_TOKEN:-}" ]; then
|
||||
repo="${IMAGE#*/}"
|
||||
tags=$(curl -sS --max-time 20 -u "${FORGEJO_REGISTRY_USER}:${FORGEJO_REGISTRY_TOKEN}" \
|
||||
"https://${FORGEJO_REGISTRY}/v2/${repo}/tags/list" 2>/dev/null)
|
||||
if [ -n "$tags" ]; then
|
||||
printf '%s' "$tags" | grep -q "\"${TAG}\"" \
|
||||
|| die "${IMAGE}:${TAG} is not published. Nothing was changed.
|
||||
Published tags: $(printf '%s' "$tags" | 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."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
|
||||
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."
|
||||
|
||||
file=$(api "${PORTAINER_URL}/api/stacks/${STACK_ID}/file" \
|
||||
| python3 -c 'import sys,json;sys.stdout.write(json.load(sys.stdin)["StackFileContent"])') \
|
||||
|| die "could not read the stack file. Nothing was changed."
|
||||
|
||||
# Rewrite the image line only if it does not already name the tag we want.
|
||||
#
|
||||
# python3 -c with the script as an ARGUMENT, not `python3 -` with a heredoc:
|
||||
# the stack file arrives on stdin, and a heredoc would claim stdin too. The
|
||||
# first version of this did exactly that, and python tried to execute the YAML.
|
||||
new_file=$(printf '%s' "$file" | python3 -c "
|
||||
import re, sys
|
||||
img, tag = sys.argv[1], sys.argv[2]
|
||||
s = sys.stdin.read()
|
||||
pat = re.compile(r'^(\s*image:\s*)' + re.escape(img) + r':\S+[ \t]*\$', re.M)
|
||||
if not pat.search(s):
|
||||
sys.exit(3)
|
||||
sys.stdout.write(pat.sub(lambda m: m.group(1) + img + ':' + tag, s))
|
||||
" "$IMAGE" "$TAG") || die "the stack file has no 'image: ${IMAGE}:<tag>' line to update.
|
||||
Nothing was changed. Check the stack file at ${PORTAINER_URL} — if the image
|
||||
name changed, DEPLOY_IMAGE here is stale."
|
||||
|
||||
if [ "$new_file" = "$file" ]; then
|
||||
say "the stack already names ${IMAGE}:${TAG} — redeploying it to pull the newest digest."
|
||||
else
|
||||
say "stack image line: $(printf '%s' "$file" | grep -oE "image: ${IMAGE}:\S+" | head -1)"
|
||||
say " -> image: ${IMAGE}:${TAG}"
|
||||
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[*]}"
|
||||
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}…"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify. A deploy that is not checked is a deploy you find out about later.
|
||||
# ---------------------------------------------------------------------------
|
||||
healthy=""
|
||||
for _ 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) healthy="yes"; break ;;
|
||||
running:starting) ;;
|
||||
"") ;;
|
||||
*) ;;
|
||||
esac
|
||||
sleep 4
|
||||
done
|
||||
|
||||
[ -n "$healthy" ] || die "${CONTAINER} did not come back healthy. THE SITE MAY BE DOWN.
|
||||
Check: bash scripts/status.sh --logs 200
|
||||
Roll back: bash scripts/deploy.sh --tag <the previous version>
|
||||
The pre-deploy backup is in \$HOME/backups/queue-north-website."
|
||||
|
||||
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)
|
||||
|
||||
fail=0
|
||||
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
|
||||
|
||||
say ""
|
||||
say "digest ${before_digest:-unknown}"
|
||||
say " -> ${after_digest:-unknown}"
|
||||
say "version ${before_version:-none} -> ${after_version:-none}"
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
die "the container is healthy but at least one public origin is not answering.
|
||||
That is the ingress, not the app — see docs/OPERATIONS.md."
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "deployed. Record it: docs/history/DEVELOPMENT_LOG.md, and close whatever"
|
||||
say "issue this shipped with the evidence above."
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the image, publish it, and leave every version string in the repository
|
||||
# agreeing with the tag that was published.
|
||||
#
|
||||
# npm run release # patch: 0.9.3 -> 0.9.4
|
||||
# npm run release -- minor # 0.9.3 -> 0.10.0
|
||||
# npm run release -- 1.0.0 # exact
|
||||
# npm run release -- --dry-run # say what it would do, change nothing
|
||||
#
|
||||
# ===========================================================================
|
||||
# ADAPTED FROM PrivacyLLC-Web's scripts/release.sh
|
||||
# ===========================================================================
|
||||
#
|
||||
# The arguments below are that script's and are kept because they were paid for
|
||||
# there. The implementation is this project's, because almost none of the
|
||||
# mechanism transferred: that project gates on `tsc` and a 1,600-test vitest
|
||||
# suite, pins an immutable version tag in its stack, and passes its public
|
||||
# origin in as a build arg. This one has no tests at all, follows a floating
|
||||
# `:dev` tag, and hard-codes its origin in three source files.
|
||||
#
|
||||
# Copying it verbatim would have produced a script that fails on its first line
|
||||
# and lies on several others.
|
||||
#
|
||||
# ## Why this exists
|
||||
#
|
||||
# Publishing here was `npm run docker:push` — build, tag `:dev`, push — with the
|
||||
# version bump as a separate thing to remember. It was not remembered:
|
||||
# `package.json` said 0.8.3 while the four most recent commits announced
|
||||
# "batch 0.9.0" through "batch 0.9.3", and **no image was ever published for any
|
||||
# of them**. The registry's newest tag is v0.8.3. Four batches of work went out
|
||||
# under a version number that names none of them.
|
||||
#
|
||||
# So the bump is not a step beside the release. It is what this command does.
|
||||
#
|
||||
# ## package.json is the source of truth
|
||||
#
|
||||
# The tag is always `v` + the version in package.json. There is no --tag flag,
|
||||
# because a flag would be a second source of truth and this script exists
|
||||
# because there were several.
|
||||
#
|
||||
# Arithmetic is `npm version`, which enforces semver. That is load-bearing: this
|
||||
# registry already holds `dev-v0.7.3` and `latest` alongside real versions, and
|
||||
# a hand-typed tag is how that happens.
|
||||
#
|
||||
# ## The ordering is the safety property
|
||||
#
|
||||
# Bump, guard, build, verify, push, and commit LAST. Never pass through a state
|
||||
# you cannot explain.
|
||||
#
|
||||
# If the build or push fails, the edits sit in the working tree — visible,
|
||||
# uncommitted, one `git checkout` from gone. If the commit came first, a failure
|
||||
# would leave `dev` carrying a commit announcing a release that was never
|
||||
# published, and `.githooks/post-commit` would already have pushed it.
|
||||
#
|
||||
# ## Two tags, and only one of them is a record
|
||||
#
|
||||
# `:vX.Y.Z` is immutable. Publishing over one is refused, because a running
|
||||
# stack would silently pull different code on its next recreate while the tag
|
||||
# said nothing had changed.
|
||||
#
|
||||
# `:dev` is a **moving pointer**, and Portainer stack 58 follows it. Moving it
|
||||
# is what makes a release deployable here at all, so this script moves it on
|
||||
# purpose — but that also means `:dev` is never evidence of what is running.
|
||||
# `scripts/status.sh` reads the digest, and now the version label, for that.
|
||||
#
|
||||
# ## What it deliberately does not do
|
||||
#
|
||||
# **It does not deploy.** Publishing an image and running it are separate
|
||||
# decisions. `scripts/deploy.py` makes the second one, and this script prints
|
||||
# the command rather than running it.
|
||||
#
|
||||
# **It does not prune the registry.** PrivacyLLC's version does, because it
|
||||
# releases often enough for that to matter. This project has published thirteen
|
||||
# tags in its whole life, deleting a published image is irreversible, and the
|
||||
# one that matters is whichever the running container was created from — which
|
||||
# is exactly the thing a newest-N rule gets wrong. Nothing here deletes anything.
|
||||
#
|
||||
# Exit codes: 0 released. 1 something failed and the message says what state it
|
||||
# left behind. 2 nothing was attempted — bad usage, or a guard that could not run.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)" || exit 1
|
||||
|
||||
IMAGE="${RELEASE_IMAGE:-dream.scheller.ltd/null/queue-north-website}"
|
||||
SITE_URL="${RELEASE_SITE_URL:-https://queuenorth.com}"
|
||||
DEV_TAG="${RELEASE_DEV_TAG:-dev}"
|
||||
REGISTRY_ENV="${RELEASE_REGISTRY_ENV:-$HOME/.openclaw/docker-registry.env}"
|
||||
|
||||
# Files carrying a version string. The Dockerfile is here because its
|
||||
# `ARG APP_VERSION=` becomes the image's org.opencontainers.image.version label,
|
||||
# which `status.sh --deployed-version` reads — left unbumped, every future
|
||||
# release would report the version of the one before it.
|
||||
FILES=(package.json package-lock.json Dockerfile)
|
||||
|
||||
# Every place the public origin is hard-coded. See the guard below.
|
||||
ORIGIN_FILES=(src/lib/seo.js src/components/SEO.jsx scripts/prerender.js)
|
||||
|
||||
say() { printf '\033[1mrelease:\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1mrelease:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
stop() { printf '\033[1mrelease:\033[0m %s\n' "$*" >&2; exit 2; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The public origin, checked before a build freezes it in.
|
||||
#
|
||||
# It is baked into the artifact and cannot be corrected without another build:
|
||||
# canonical URLs, og:url, sitemap.xml and robots.txt are all prerendered from
|
||||
# it. A wrong one is silent, ships, and is found by somebody wondering why
|
||||
# Google indexed a staging host.
|
||||
#
|
||||
# This project differs from the one this script came from in a way that matters:
|
||||
# there the origin arrives as a build arg, so there is one copy. Here it is
|
||||
# written out in three source files, so the guard is not "is the value sane" but
|
||||
# "do all three still agree" — three copies of a constant is a drift waiting to
|
||||
# happen, and the drift would be invisible until somebody read the page source.
|
||||
# ---------------------------------------------------------------------------
|
||||
case "$SITE_URL" in
|
||||
https://*)
|
||||
printf '%s' "$SITE_URL" | grep -Eq '^https://[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,}$' \
|
||||
|| die "RELEASE_SITE_URL is not a bare https origin: ${SITE_URL}
|
||||
It is baked into every canonical URL, og:url, the sitemap and robots.txt,
|
||||
and cannot be changed without another build. Expected something like
|
||||
https://queuenorth.com with no path and no trailing slash." ;;
|
||||
*)
|
||||
die "RELEASE_SITE_URL must be an https origin, got: ${SITE_URL:-<empty>}
|
||||
http would publish canonical URLs this deployment does not serve." ;;
|
||||
esac
|
||||
|
||||
for f in "${ORIGIN_FILES[@]}"; do
|
||||
[ -f "$f" ] || die "$f is missing, so the public origin could not be checked.
|
||||
That file is one of the places ${SITE_URL} is written down; if it moved,
|
||||
update ORIGIN_FILES in this script before releasing."
|
||||
grep -Fq "$SITE_URL" "$f" \
|
||||
|| die "$f does not contain ${SITE_URL}.
|
||||
The public origin is hard-coded in ${#ORIGIN_FILES[@]} files and they have drifted.
|
||||
Whatever this file says instead is what will be baked into the canonical
|
||||
URLs, and it is not what you asked for. Fix it before releasing."
|
||||
done
|
||||
|
||||
BUMP="patch"
|
||||
DRY_RUN=""
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN="yes" ;;
|
||||
-h|--help) say "usage: npm run release -- [patch|minor|major|<version>] [--dry-run]"; exit 0 ;;
|
||||
-*) stop "unknown flag $1. Usage: npm run release -- [patch|minor|major|<version>] [--dry-run]" ;;
|
||||
*) BUMP="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
command -v docker >/dev/null 2>&1 || stop "docker is not on PATH. Nothing was attempted."
|
||||
command -v npm >/dev/null 2>&1 || stop "npm is not on PATH. Nothing was attempted."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A clean tree, because the commit at the end stages by explicit path and
|
||||
# anything else sitting there would be released without being looked at.
|
||||
# ---------------------------------------------------------------------------
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
say "the working tree has uncommitted changes:"
|
||||
git status --short >&2
|
||||
die "commit or stash them first. This script commits ${FILES[*]} at the end and
|
||||
cannot tell your work in progress from a release."
|
||||
fi
|
||||
|
||||
current=$(node -p "require('./package.json').version" 2>/dev/null) \
|
||||
|| stop "could not read the version from package.json."
|
||||
|
||||
# Compute the next version without writing it, so the already-published check
|
||||
# can refuse before anything is touched.
|
||||
work=$(mktemp -d) || stop "could not create a scratch directory."
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
cp package.json "$work/" 2>/dev/null || stop "could not copy package.json to scratch."
|
||||
|
||||
next=$(cd "$work" && npm version --no-git-tag-version "$BUMP" 2>/dev/null | tr -d 'v\n')
|
||||
[ -n "$next" ] || die "npm rejected '$BUMP' as a version bump."
|
||||
|
||||
TAG="v${next}"
|
||||
say "$current -> $next (publishing ${IMAGE}:${TAG}, and moving :${DEV_TAG})"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refuse to move a tag that is already published.
|
||||
#
|
||||
# Overwriting one silently changes what a stack pulls on its next recreate,
|
||||
# while the tag says nothing changed. Immutable by convention; this makes it
|
||||
# immutable in practice.
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -r "$REGISTRY_ENV" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
set -a; . "$REGISTRY_ENV"; set +a
|
||||
fi
|
||||
|
||||
if [ -n "${FORGEJO_REGISTRY:-}" ] && [ -n "${FORGEJO_REGISTRY_TOKEN:-}" ]; then
|
||||
repo="${IMAGE#*/}"
|
||||
tags=$(curl -sS --max-time 20 -u "${FORGEJO_REGISTRY_USER}:${FORGEJO_REGISTRY_TOKEN}" \
|
||||
"https://${FORGEJO_REGISTRY}/v2/${repo}/tags/list" 2>/dev/null)
|
||||
|
||||
if printf '%s' "$tags" | grep -q "\"${TAG}\""; then
|
||||
die "${TAG} is already published. Pick a higher version — a published tag is not moved."
|
||||
fi
|
||||
if [ -z "$tags" ]; then
|
||||
# Said out loud. "I could not check" and "it is not there" are different
|
||||
# answers and only one of them is safe to act on.
|
||||
say "WARNING: could not read the registry tag list. Proceeding WITHOUT the"
|
||||
say " already-published check."
|
||||
fi
|
||||
else
|
||||
say "WARNING: no registry credentials at $REGISTRY_ENV — cannot check whether"
|
||||
say " ${TAG} is already published, and the push will likely fail."
|
||||
fi
|
||||
|
||||
if [ -n "$DRY_RUN" ]; then
|
||||
say "--dry-run: nothing was changed. It would have:"
|
||||
say " set version ${next} in ${FILES[*]}"
|
||||
say " bash scripts/verify.sh"
|
||||
say " docker build --build-arg APP_VERSION=${next} -t ${IMAGE}:${TAG} ."
|
||||
say " verify the image's org.opencontainers.image.version label reads ${next}"
|
||||
say " docker tag ${IMAGE}:${TAG} ${IMAGE}:${DEV_TAG}"
|
||||
say " docker push ${IMAGE}:${TAG} and ${IMAGE}:${DEV_TAG}"
|
||||
say " git commit -m 'chore(release): ${TAG}' (post-commit then pushes)"
|
||||
say " git tag ${TAG} && git push origin ${TAG}"
|
||||
say ""
|
||||
say "It would NOT deploy. Portainer stack 58 keeps running the image it has"
|
||||
say "until scripts/deploy.py is run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bump. Files first, so the image is built from the source that names it.
|
||||
# ---------------------------------------------------------------------------
|
||||
npm version --no-git-tag-version --allow-same-version "$next" >/dev/null \
|
||||
|| die "npm version failed; nothing has been built or pushed."
|
||||
|
||||
perl -pi -e "s{^ARG APP_VERSION=.*}{ARG APP_VERSION=${next}}" Dockerfile \
|
||||
|| die "could not rewrite ARG APP_VERSION in the Dockerfile."
|
||||
|
||||
# Checked, not assumed. A rewrite that silently matched nothing would publish an
|
||||
# image whose version label names the previous release.
|
||||
grep -q "^ARG APP_VERSION=${next}\$" Dockerfile \
|
||||
|| die "ARG APP_VERSION in the Dockerfile did not update — refusing to build.
|
||||
The bump is in your working tree; 'git checkout -- ${FILES[*]}' undoes it."
|
||||
|
||||
say "bumped ${FILES[*]}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Guards, before anything is built.
|
||||
#
|
||||
# PrivacyLLC's version runs a typecheck and a 1,600-test suite here, and refuses
|
||||
# to release on a half-run one. **This project has neither**, and that is stated
|
||||
# rather than papered over: scripts/verify.sh runs the build, the tracked-tree
|
||||
# secret scan and the document-header check, and none of those is a test.
|
||||
#
|
||||
# So this gate proves the artifact compiles and carries no credential. It does
|
||||
# not prove the site works. docs/qa/ClaudeQACoverage.md is honest about what
|
||||
# that leaves untested, which is nearly everything.
|
||||
# ---------------------------------------------------------------------------
|
||||
say "guards…"
|
||||
|
||||
if ! bash scripts/verify.sh; then
|
||||
say "verify.sh failed. The version bump is in your working tree and NOTHING was"
|
||||
say " built, published or committed. Fix it and run again, or"
|
||||
say " 'git checkout -- ${FILES[*]}' to undo the bump."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
say "NOTE: those guards are a build, a secret scan and a doc-header check."
|
||||
say " There is no test suite in this repository, so nothing above"
|
||||
say " exercised a single route, form or API response."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build, verify what came out, then push. Nothing is committed until all three
|
||||
# have succeeded.
|
||||
# ---------------------------------------------------------------------------
|
||||
say "building ${IMAGE}:${TAG}…"
|
||||
|
||||
if ! docker build \
|
||||
--build-arg "APP_VERSION=${next}" \
|
||||
--build-arg "VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-}" \
|
||||
-t "${IMAGE}:${TAG}" . ; then
|
||||
say "build failed. The version bump is in your working tree and NOTHING was"
|
||||
say " published or committed. 'git checkout -- ${FILES[*]}' undoes it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ask the image rather than trusting the wiring. The Dockerfile threads
|
||||
# APP_VERSION through an ARG into a LABEL; break either and the build still
|
||||
# succeeds, and the only symptom is `status.sh --deployed-version` reporting the
|
||||
# wrong version during an incident — which is the moment it is trusted most.
|
||||
say "verifying the image reports ${next}…"
|
||||
|
||||
baked=$(docker image inspect "${IMAGE}:${TAG}" \
|
||||
--format '{{index .Config.Labels "org.opencontainers.image.version"}}' 2>/dev/null | tr -d '\r\n')
|
||||
|
||||
if [ "$baked" != "$next" ]; then
|
||||
# Remove the local tag. A correctly-named image with the wrong contents is a
|
||||
# loaded gun for a later hand-typed `docker push`.
|
||||
docker rmi "${IMAGE}:${TAG}" >/dev/null 2>&1
|
||||
say "the image's version label reads '${baked:-nothing}' but should read ${next}."
|
||||
say " Check ARG APP_VERSION and the LABEL in the runner stage of the"
|
||||
say " Dockerfile. Nothing was published or committed; the local image"
|
||||
say " was removed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The pointer stack 58 follows. Tagged after the version tag is verified, so
|
||||
# :dev can never point at an image that failed its own check.
|
||||
docker tag "${IMAGE}:${TAG}" "${IMAGE}:${DEV_TAG}" \
|
||||
|| die "could not tag ${IMAGE}:${DEV_TAG}; nothing was pushed."
|
||||
|
||||
say "pushing ${IMAGE}:${TAG}…"
|
||||
if ! docker push "${IMAGE}:${TAG}"; then
|
||||
say "push failed. The image exists locally and the bump is in your working"
|
||||
say " tree, but nothing was published or committed. Check the registry"
|
||||
say " login (docker login ${IMAGE%%/*}) and run this again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
say "moving ${IMAGE}:${DEV_TAG}…"
|
||||
if ! docker push "${IMAGE}:${DEV_TAG}"; then
|
||||
say "WARNING: ${TAG} IS published, but :${DEV_TAG} was not moved. Stack 58"
|
||||
say " follows :${DEV_TAG}, so a deploy right now would redeploy the"
|
||||
say " PREVIOUS image. Push it by hand before deploying:"
|
||||
say " docker push ${IMAGE}:${DEV_TAG}"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commit last, by explicit path.
|
||||
# ---------------------------------------------------------------------------
|
||||
git add -- "${FILES[@]}" || die "git add failed after a successful push — commit ${FILES[*]} by hand."
|
||||
|
||||
if ! git commit -q -m "chore(release): ${TAG}"; then
|
||||
say "the commit was refused (see above). The image IS published as ${TAG};"
|
||||
say " only the commit is missing. Fix and commit ${FILES[*]} by hand."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# After the commit, so the tag has something to point at. post-commit pushes the
|
||||
# branch and never --tags, so the tag needs a push of its own.
|
||||
if git tag "${TAG}" >/dev/null 2>&1 && git push -q origin "${TAG}" >/dev/null 2>&1; then
|
||||
say "tagged ${TAG}."
|
||||
else
|
||||
# Not fatal. A release claims an image was built, checked and published; all
|
||||
# three are true by now. A missing tag does not make any of them false.
|
||||
say "note: the git tag was not created or not pushed. The image IS published"
|
||||
say " and the commit is made. Run: git tag ${TAG} && git push origin ${TAG}"
|
||||
fi
|
||||
|
||||
say "released ${TAG}."
|
||||
say ""
|
||||
say "This published an image. It did NOT deploy it — Portainer stack 58 is still"
|
||||
say "running whatever it was running before. To move it:"
|
||||
say ""
|
||||
say " python3 scripts/deploy.py"
|
||||
say ""
|
||||
say "and see docs/OPERATIONS.md, which covers what a redeploy costs: it recreates"
|
||||
say "the container and takes both public front doors down together."
|
||||
Loading…
Reference in New Issue