350 lines
16 KiB
Bash
Executable File
350 lines
16 KiB
Bash
Executable File
#!/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.
|
|
#
|
|
# ## One tag, and it is a record
|
|
#
|
|
# `: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.
|
|
#
|
|
# **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
|
|
#
|
|
# **It does not deploy.** Publishing an image and running it are separate
|
|
# decisions. `scripts/deploy.py` makes the second one, and this script prints
|
|
# the command rather than running it.
|
|
#
|
|
# **It does not prune the registry.** PrivacyLLC's version does, because it
|
|
# releases often enough for that to matter. This project has published thirteen
|
|
# tags in its whole life, deleting a published image is irreversible, and the
|
|
# one that matters is whichever the running container was created from — which
|
|
# is exactly the thing a newest-N rule gets wrong. Nothing here deletes anything.
|
|
#
|
|
# Exit codes: 0 released. 1 something failed and the message says what state it
|
|
# left behind. 2 nothing was attempted — bad usage, or a guard that could not run.
|
|
|
|
set -uo pipefail
|
|
|
|
cd "$(git rev-parse --show-toplevel)" || exit 1
|
|
|
|
IMAGE="${RELEASE_IMAGE:-dream.scheller.ltd/null/queue-north-website}"
|
|
SITE_URL="${RELEASE_SITE_URL:-https://queuenorth.com}"
|
|
REGISTRY_ENV="${RELEASE_REGISTRY_ENV:-$HOME/.openclaw/docker-registry.env}"
|
|
|
|
# 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})"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 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, and it would NOT move :dev — production runs a"
|
|
say "numbered version. Afterwards: npm run deploy -- --tag ${TAG}"
|
|
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
|
|
|
|
say "pushing ${IMAGE}:${TAG}…"
|
|
if ! docker push "${IMAGE}:${TAG}"; then
|
|
say "push failed. The image exists locally and the bump is in your working"
|
|
say " tree, but nothing was published or committed. Check the registry"
|
|
say " login (docker login ${IMAGE%%/*}) and run this again."
|
|
exit 1
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Commit last, by explicit path.
|
|
# ---------------------------------------------------------------------------
|
|
git add -- "${FILES[@]}" || die "git add failed after a successful push — commit ${FILES[*]} by hand."
|
|
|
|
if ! git commit -q -m "chore(release): ${TAG}"; then
|
|
say "the commit was refused (see above). The image IS published as ${TAG};"
|
|
say " only the commit is missing. Fix and commit ${FILES[*]} by hand."
|
|
exit 1
|
|
fi
|
|
|
|
# After the commit, so the tag has something to point at. post-commit pushes the
|
|
# branch and never --tags, so the tag needs a push of its own.
|
|
if git tag "${TAG}" >/dev/null 2>&1 && git push -q origin "${TAG}" >/dev/null 2>&1; then
|
|
say "tagged ${TAG}."
|
|
else
|
|
# Not fatal. A release claims an image was built, checked and published; all
|
|
# three are true by now. A missing tag does not make any of them false.
|
|
say "note: the git tag was not created or not pushed. The image IS published"
|
|
say " and the commit is made. Run: git tag ${TAG} && git push origin ${TAG}"
|
|
fi
|
|
|
|
say "released ${TAG}."
|
|
say ""
|
|
say "This published an image. It did NOT deploy it — Portainer stack 58 is still"
|
|
say "running whatever it was running before. To move it to this version:"
|
|
say ""
|
|
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."
|