359 lines
16 KiB
Bash
359 lines
16 KiB
Bash
|
|
#!/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."
|