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>
This commit is contained in:
Neo 2026-08-18 02:54:42 -05:00
parent 6a890ad8bc
commit 1a3ba305a7
9 changed files with 152 additions and 96 deletions

View File

@ -249,20 +249,46 @@ 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:
### Production always runs a numbered version
**Policy, set 2026-08-18.** The stack pins `:vX.Y.Z`. Never `:dev`, never
`:latest`, never any other pointer.
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` enforces it. With no `--tag` it resolves the **newest published
numbered version** from the registry and says which it picked; given a floating
tag it refuses:
```bash
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
npm run deploy # newest published vX.Y.Z
npm run deploy -- --tag v0.9.4 --dry-run # a specific version, previewed
npm run deploy -- --tag v0.9.4 # deploy it
npm run deploy -- --tag v0.8.3 # roll back to a known image
```
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.
`--allow-floating` exists for the one case that is not a mistake — reproducing a
fault on whatever a pointer currently is — and warns every time.
It also refuses a tag the registry does not hold. A missing tag would otherwise
become an outage: Portainer accepts the file, fails to pull, and leaves the
container stopped.
**Stack 58 pins `:dev` as this is written**, which is the state the policy
corrects. The first deploy made under it repoints the stack at a version, and
after that the stack file answers the question on its own.
`:dev` and `:latest` remain in the registry, frozen. They are not deleted
because the running container was created from `:dev`, and removing the tag an
existing deployment names is how a recreate fails to pull. Nothing publishes to
them any more — `release.sh` stopped moving `:dev` when this policy was set, and
scripts/docker-push.sh — named without backticks because it is gone — was
deleted, its only job having been to push an unnumbered `:dev` with no version
bump and no guards.
**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

View File

@ -127,9 +127,9 @@ 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 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 release` | **publish an image.** Bump, guards, build, verify its version label, push `:vX.Y.Z`, commit last, tag. `-- --dry-run` first |
| `npm run deploy` | **move the running stack** to a published **numbered** version — the newest by default, `-- --tag vX.Y.Z` to pin or roll back. Backs up first, and refuses a floating tag. `-- --dry-run` first |
| `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

View File

@ -304,9 +304,9 @@ one, and never report progress the API did not return.
- **Not the work list.** That is the tracker: milestones are batches, issues are
deliverables.
- **Not the release procedure.** This project has no `release.sh` — publishing
is `npm run docker:push`, and how nebula moves to a new image is not yet
written down. `docs/OPERATIONS.md` records that gap;
- **Not the release procedure.** That is `npm run release` to publish and
`npm run deploy` to move the stack — two commands on purpose. `scripts/release.sh`
and `docs/OPERATIONS.md` carry the detail, and
`docs/security/SECURITY_CHECKLIST.md` is the list to work through first.
- **Not the QA procedure.** That is `docs/qa/ClaudeQAPlan.md`, which ends in its
own version of step 7.

View File

@ -150,9 +150,9 @@ 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/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/release.sh` | **publishes.** Bump, guard, build, verify the image's own version label, push `:vX.Y.Z`, 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 **numbered** version — it refuses a floating tag — taking a verified backup first and preserving the stack's twelve environment variables, then waits for health and checks both public origins. Reports the digest before and after |
| `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 |
@ -171,9 +171,9 @@ each is a fact about this project rather than a preference:
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 publishes one tag and does not move `:dev`.** Both scripts follow the
policy below: production always runs a numbered version, so nothing deploys a
pointer and publishing one would only misrepresent what is running.
- **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`,

View File

@ -113,6 +113,7 @@ Observable, in this order:
- **No automated tests at all.** No test runner, no typecheck. `npm run build` is
the only gate, and it catches syntax and imports rather than behaviour. Recorded
as a standing gap in `docs/qa/ClaudeQACoverage.md`.
- **The roll-forward procedure is not written down.** Publishing is
`scripts/docker-push.sh`; how nebula moves to a new image is currently in
somebody's head. Noted in `docs/OPERATIONS.md`.
- **Deployment is scripted but has never been run end to end.** `npm run
release` publishes and `npm run deploy` moves Portainer stack 58, both added
2026-08-18 and both exercised only in `--dry-run`. The first real run of either
is still ahead.

View File

@ -18,7 +18,6 @@
"docker:compose:up": "docker-compose up -d",
"docker:compose:down": "docker-compose down",
"docker:compose:logs": "docker-compose logs -f",
"docker:push": "bash scripts/docker-push.sh",
"docker:test": "bash scripts/docker-test.sh"
},
"dependencies": {

View File

@ -2,8 +2,9 @@
#
# 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 # 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
# bash scripts/deploy.sh --dry-run # show the change, touch nothing
# bash scripts/deploy.sh --no-backup # skip the pre-deploy dump (say why)
#
@ -36,15 +37,25 @@
# 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
# ## THE POLICY: production always runs a numbered version
#
# 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.
# Set 2026-08-18. The stack pins `:vX.Y.Z` and never a floating tag.
#
# Either way the digest is reported before and after, because that is the only
# `: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
# thing that cannot be moved underneath you.
set -uo pipefail
@ -64,9 +75,10 @@ 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}"
TAG=""
DRY_RUN=""
NO_BACKUP=""
ALLOW_FLOATING=""
while [ "$#" -gt 0 ]; do
case "$1" in
@ -74,7 +86,8 @@ while [ "$#" -gt 0 ]; do
--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 ;;
--allow-floating) ALLOW_FLOATING="yes" ;;
-h|--help) say "usage: bash scripts/deploy.sh [--tag vX.Y.Z] [--dry-run] [--no-backup] [--allow-floating]"; exit 0 ;;
*) stop "unknown argument '$1'. Run --help." ;;
esac
shift
@ -102,19 +115,67 @@ api() { curl -sk --max-time 30 -H "X-API-Key: $KEY" "$@"; }
REG_ENV="${RELEASE_REGISTRY_ENV:-$HOME/.openclaw/docker-registry.env}"
if [ -r "$REG_ENV" ]; then set -a; . "$REG_ENV"; set +a; fi
tags_json=""
if [ -n "${FORGEJO_REGISTRY:-}" ] && [ -n "${FORGEJO_REGISTRY_TOKEN:-}" ]; then
repo="${IMAGE#*/}"
tags=$(curl -sS --max-time 20 -u "${FORGEJO_REGISTRY_USER}:${FORGEJO_REGISTRY_TOKEN}" \
tags_json=$(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}\"" \
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."
fi
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" | python3 -c 'import sys,json;print(", ".join(json.load(sys.stdin).get("tags") or []))' 2>/dev/null)"
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."
fi
fi
# ---------------------------------------------------------------------------
# What is running now. Reported before anything changes, so the two halves of

View File

@ -1,22 +0,0 @@
#!/usr/bin/env bash
# docker-push.sh — Tag and push dev image to Forgejo registry
# Usage: ./scripts/docker-push.sh
# Requires: ~/.openclaw/docker-registry.env (chmod 600)
set -euo pipefail
cd "$(dirname "$0")/.."
source ~/.openclaw/docker-registry.env
# Build image via docker compose
DOCKER_API_VERSION=1.44 docker compose build
# Tag and push dev
IMAGE_NAME="queue-north-website-queuenorth"
docker tag "${IMAGE_NAME}:latest" "${FORGEJO_REGISTRY}/null/queue-north-website:dev"
echo "$FORGEJO_REGISTRY_TOKEN" | docker login "$FORGEJO_REGISTRY" -u "$FORGEJO_REGISTRY_USER" --password-stdin
docker push "${FORGEJO_REGISTRY}/null/queue-north-website:dev"
docker logout "$FORGEJO_REGISTRY"
echo "✓ Pushed dev image"

View File

@ -53,16 +53,21 @@
# 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
# ## One tag, and it 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.
# `:vX.Y.Z`, immutable. Publishing over one is refused, because a running stack
# would silently pull different code on its next recreate while the tag said
# nothing had changed.
#
# `: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.
# **It does not move `:dev`, deliberately.** An earlier version of this script
# did, because stack 58 followed that pointer. The policy changed on 2026-08-18:
# production always runs a numbered version, so nothing deploys `:dev` any more
# and moving it would publish a pointer that misrepresents what is running to
# anybody who reads it. `scripts/deploy.sh` refuses a floating tag outright.
#
# The `:dev` and `:latest` tags already in this registry are frozen where they
# are rather than deleted — the running container was created from `:dev`, and
# deleting the tag an existing deployment names is how a recreate fails to pull.
#
# ## What it deliberately does not do
#
@ -85,7 +90,6 @@ 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
@ -178,7 +182,7 @@ next=$(cd "$work" && npm version --no-git-tag-version "$BUMP" 2>/dev/null | tr -
[ -n "$next" ] || die "npm rejected '$BUMP' as a version bump."
TAG="v${next}"
say "$current -> $next (publishing ${IMAGE}:${TAG}, and moving :${DEV_TAG})"
say "$current -> $next (publishing ${IMAGE}:${TAG})"
# ---------------------------------------------------------------------------
# Refuse to move a tag that is already published.
@ -217,13 +221,12 @@ if [ -n "$DRY_RUN" ]; then
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 " docker push ${IMAGE}:${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."
say "It would NOT deploy, and it would NOT move :dev — production runs a"
say "numbered version. Afterwards: npm run deploy -- --tag ${TAG}"
exit 0
fi
@ -304,11 +307,6 @@ if [ "$baked" != "$next" ]; then
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"
@ -317,14 +315,6 @@ if ! docker push "${IMAGE}:${TAG}"; then
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.
# ---------------------------------------------------------------------------
@ -350,9 +340,10 @@ 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 "running whatever it was running before. To move it to this version:"
say ""
say " python3 scripts/deploy.py"
say " npm run deploy -- --tag ${TAG} --dry-run"
say " npm run deploy -- --tag ${TAG}"
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."