#!/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:-} 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|] [--dry-run]"; exit 0 ;; -*) stop "unknown flag $1. Usage: npm run release -- [patch|minor|major|] [--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."