Project-Template/docs/architecture/scripts/release.sh

714 lines
31 KiB
Bash
Raw Permalink Normal View History

#!/usr/bin/env bash
#
# Build the container, push it, and leave every version string in the repo
# agreeing with the tag that was published.
#
# npm run release # patch: 0.46.0 -> 0.46.1
# npm run release -- minor # 0.46.0 -> 0.47.0
# npm run release -- 1.0.0 # exact
# npm run release -- --dry-run
# npm run release -- --no-prune # keep every published version
# RELEASE_KEEP=10 npm run release # keep ten instead of two
#
# ===========================================================================
# TEMPLATE COPY — configure this before the first run
# ===========================================================================
#
# Copy to `scripts/release.sh`, add `"release": "bash scripts/release.sh"` to
# package.json, and set the five values in the CONFIGURATION block below. The
# script refuses to run until they are set: it has no defaults, deliberately.
#
# The version it was adapted from carried its origin project's image name,
# deploy host and container name as defaults. Copied into a second project and
# run once, that would have built the new project's code, pushed it OVER the
# first project's image, pruned the first project's published versions, and
# read the first project's running container to decide what was safe to delete.
# Every one of those is silent and none is reversible. Hence: no defaults, and
# a loud failure instead.
#
# Assumes: bash, git, docker, ssh, and a Node project (`npm version` does the
# semver arithmetic). For a non-Node project, replace the two `npm version`
# calls and the `node -p` reader; everything else is language-agnostic.
#
# Assumes a registry with a Forgejo-compatible package API for the prune. If
# yours differs, the prune is the only part to rewrite — it is at the bottom
# and nothing else depends on it.
#
# ## Why this exists
#
# The project this came from had no build or push command at all. `git grep
# "docker push"` found nothing, there was no CI, and the README documented a
# `docker build` line and never the push. Every release was two commands typed
# from memory, and the version bump was a third thing to remember afterwards.
#
# It was not remembered. `package.json` sat at 0.2.0 across twenty-nine image
# releases because nothing read it and nothing checked it; the README and the
# example compose file stayed pinned at v0.40.01 while production ran v0.46.0.
# The git history shows the ritual dying in stages: one `chore(release)` commit
# touched only `.dockerignore`, and two others were empty.
#
# So the version bump is not a step beside the release. It is what the release
# command does, and a test fails when the files disagree. Write that test — see
# "The guard test" at the bottom of this header.
#
# ## package.json is the source of truth
#
# The tag is always `v` + the version in package.json. Nothing else decides it,
# which is why there is no --tag flag: a flag would be a second source of truth
# and this whole script exists because there were four.
#
# Arithmetic is `npm version`, which is built in and enforces semver. That is
# load-bearing rather than incidental — it makes a tag like `v0.40.01` (leading
# zero in the patch, not valid semver, and a real tag in this registry)
# impossible to produce from now on.
#
# ## The ordering is the safety property
#
# Bump, build, push, and commit LAST. The rule is: never pass through a state
# you cannot explain to somebody reading the repository afterwards.
#
# If the build or the push fails, the edits are sitting in the working tree —
# visible, uncommitted, unpushed, one `git checkout` from gone. If the commit
# came first, a failure would leave `main` carrying a commit that announces a
# release which was never published, and `post-commit` would have already
# pushed it to everyone. One of those is recoverable by noticing; the other is
# not.
#
# ## What it deliberately does not do
#
# It does not deploy. Whatever runs the container pins a tag, and updating that
# pin stays a separate, deliberate act. Publishing an image and running it are
# two decisions and this script only makes the first one — so a bad build sits
# in a registry rather than in production.
#
# ## The guard test
#
# This script is only half the mechanism. The other half is a test asserting
# that every file in FILES/PIN_FILES names the same version, so a hand-edit or
# a half-finished release fails the suite instead of shipping. Without it,
# nothing notices the drift this script was written to end — which is exactly
# how the original got to four disagreeing version strings.
set -uo pipefail
cd "$(git rev-parse --show-toplevel)" || exit 1
# ---------------------------------------------------------------------------
# CONFIGURATION — set these five, then delete this banner.
#
# Every one is empty on purpose. See the note at the top: inherited defaults
# from another project point a release at that project's image, registry and
# running container, and every consequence of that is silent.
# ---------------------------------------------------------------------------
# Fully-qualified image name, no tag. e.g. registry.example.com/team/my-app
IMAGE="${RELEASE_IMAGE:-}"
# The public origin, when the build bakes one in.
#
# Left empty by default: a project whose artifact carries no origin should not
# have one invented for it. But when it is set it is usually frozen into the
# build — canonical URLs, Open Graph tags, robots.txt, sitemaps — and cannot be
# corrected without another build, so a wrong value ships silently and is found
# by somebody else. Validated below rather than trusted.
SITE_URL="${RELEASE_SITE_URL:-}"
# A file sourced for FORGEJO_REGISTRY / FORGEJO_REGISTRY_USER /
# FORGEJO_REGISTRY_TOKEN. Keep it OUTSIDE the repository — a token in a file
# the repo can see is a token one `git add -A` away from being published.
REGISTRY_ENV="${RELEASE_REGISTRY_ENV:-}"
# The files that carry a version string. Every one of them is checked before
# the build and staged after the push; the guard test asserts they agree.
#
# FILES is what `npm version` rewrites and must exist. PIN_FILES carry an
# `IMAGE:vX.Y.Z` pin rewritten by search-and-replace; list only the ones this
# project actually has. A project with no compose file leaves PIN_FILES holding
# just the README, or empty — an absent file is skipped with a warning rather
# than failing the release, because "you do not have that file" and "the
# rewrite silently matched nothing" need different answers.
FILES=(package.json package-lock.json)
PIN_FILES=(README.md docker-compose.example.yml)
say() { printf '\033[1mrelease:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1mrelease:\033[0m %s\n' "$*" >&2; exit 1; }
# Refused, never corrected: guessing what somebody meant by a malformed origin
# is how the wrong one gets baked in with a reassuring log line above it.
if [ -n "$SITE_URL" ]; then
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}
Expected something like https://example.com, with no path." ;;
*)
die "RELEASE_SITE_URL must be an https origin, got: ${SITE_URL}" ;;
esac
fi
BUMP="patch"
DRY_RUN=""
NO_PRUNE=""
# How many versions survive a prune, newest first — published AND local.
#
# Two is the floor the check below enforces, and it is now also the default:
# the newest, plus one to roll back to. It is deliberately tight because the
# count that used to matter — how much of the registry we keep — turned out not
# to be the expensive one. Releases here are frequent and the deployed tag is
# spared on top of this number whatever it is, so the practical floor is three
# images, not two.
KEEP="${RELEASE_KEEP:-2}"
# Where the running stack lives, so a prune can find out what is deployed and
# refuse to delete it. See the prune section for why this is not optional.
#
# DEPLOY_HOST is an ssh destination — a Host in ~/.ssh/config, not a password
# prompt: the lookup runs with BatchMode=yes and a host that asks for input is
# read as "could not determine", which correctly stops the prune.
DEPLOY_HOST="${RELEASE_DEPLOY_HOST:-}"
DEPLOY_CONTAINER="${RELEASE_DEPLOY_CONTAINER:-}"
# ---------------------------------------------------------------------------
# Refuse to run half-configured.
#
# Checked here, before the first side effect, and named one at a time so the
# message says which value is missing rather than "configuration error".
# ---------------------------------------------------------------------------
[ -n "$IMAGE" ] || die "set IMAGE (or RELEASE_IMAGE) — the image name to build and push. See the CONFIGURATION block."
[ -n "$REGISTRY_ENV" ] || die "set REGISTRY_ENV (or RELEASE_REGISTRY_ENV) — the file holding the registry credentials."
[ -n "$DEPLOY_HOST" ] || die "set DEPLOY_HOST (or RELEASE_DEPLOY_HOST) — the ssh host running the deployed container."
[ -n "$DEPLOY_CONTAINER" ] || die "set DEPLOY_CONTAINER (or RELEASE_DEPLOY_CONTAINER) — the container name to read the deployed version from."
# Refused rather than defaulted, because the failure is a publication.
#
# Docker treats the first path component as a registry only when it contains a
# dot or a colon (or is localhost); anything else is a Docker Hub namespace. So
# `acme/my-app` is not a private registry that happens to be unreachable — it is
# Docker Hub, and a successful push there makes a private project public. That
# is not a mistake to discover from the registry's web UI.
case "${IMAGE%%/*}" in
*.*|*:*|localhost) : ;;
*) die "IMAGE ('$IMAGE') has no registry host — '${IMAGE%%/*}' is a Docker Hub
namespace, so pushing would publish this image publicly. Use a
fully-qualified name like registry.example.com/team/app." ;;
esac
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN="yes" ;;
--no-prune) NO_PRUNE="yes" ;;
-*) die "unknown flag $arg. Usage: npm run release -- [patch|minor|major|<version>] [--dry-run] [--no-prune]" ;;
*) BUMP="$arg" ;;
esac
done
case "$KEEP" in
''|*[!0-9]*) die "RELEASE_KEEP must be a whole number, got '$KEEP'." ;;
esac
# Keeping zero would delete the tag this run just published. Refused rather than
# clamped, because a caller who typed 0 meant something and it was not that.
[ "$KEEP" -ge 2 ] || die "RELEASE_KEEP must be at least 2 — keeping fewer leaves nothing to roll back to."
# What production is actually running, asked once and remembered.
#
# Both prunes need it and neither may delete without it, so it is a function
# rather than a line copied twice — a second copy is a second thing to keep
# correct, and the consequence of getting it wrong is deleting the image the
# running container was created from.
#
# The empty string means "could not determine", which every caller must treat
# as a reason to stop rather than as "nothing is deployed".
DEPLOYED_VERSION=""
DEPLOYED_ASKED=""
deployed_version() {
if [ -z "$DEPLOYED_ASKED" ]; then
DEPLOYED_ASKED="yes"
DEPLOYED_VERSION=$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$DEPLOY_HOST" \
"docker inspect --format '{{index .Config.Labels \"org.opencontainers.image.version\"}}' $DEPLOY_CONTAINER" \
2>/dev/null | tr -d '\r\n')
fi
printf '%s' "$DEPLOYED_VERSION"
}
# ---------------------------------------------------------------------------
# The same trim, applied to this machine.
#
# `prune` below deletes published versions. Nothing ever deleted the images this
# script BUILDS, so every release since the first left another ~250 MB tag on
# the disk: seventy-four of them by the time anyone looked, inside a daemon
# holding 79 GB with 47 GB reclaimable.
#
# ## Scoped to this image by name, and to nothing else
#
# `docker image prune` is one line and would have reached every project sharing
# this daemon, including a build running in another checkout at that moment.
# Dangling layers are left alone for the same reason — an untagged layer is not
# identifiable as ours, and "probably nobody's" is not ownership. This removes
# tags of $IMAGE matching vN.N.N and nothing else.
#
# ## The deployed tag is spared here too
#
# A local image is not the copy production pulls, so deleting one is far less
# dangerous than deleting a published version — but it is the copy that makes a
# rollback instant instead of a download, and it costs nothing to keep. Same
# rule, same reason: if the deployed version cannot be read, this does nothing.
# ---------------------------------------------------------------------------
prune_local() {
[ -z "$NO_PRUNE" ] || { say "prune: local images skipped (--no-prune)."; return 0; }
local deployed
deployed=$(deployed_version)
if [ -z "$deployed" ]; then
say "prune: local images SKIPPED — could not read the deployed version."
return 0
fi
local tags total keep_from doomed
tags=$(docker images --format '{{.Tag}}' "$IMAGE" 2>/dev/null \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V)
total=$(printf '%s\n' "$tags" | grep -c .)
if [ "$total" -le "$KEEP" ]; then
say "prune: ${total} local image(s), keeping ${KEEP} — nothing to remove."
return 0
fi
keep_from=$((total - KEEP))
doomed=$(printf '%s\n' "$tags" | head -n "$keep_from" \
| grep -v "^${deployed}$" | grep -v "^${TAG}$")
if [ -z "$doomed" ]; then
say "prune: no local images to remove."
return 0
fi
if [ -n "$DRY_RUN" ]; then
say "prune: would remove $(printf '%s\n' "$doomed" | grep -c .) local image(s):"
printf ' %s\n' $doomed >&2
return 0
fi
say "prune: removing $(printf '%s\n' "$doomed" | grep -c .) local image(s), keeping"
say " the newest ${KEEP} plus ${deployed} (deployed)."
local tag
for tag in $doomed; do
# Untagging is all that is asked for. Layers shared with a kept image stay,
# and the space is reclaimed only when the last tag referencing them goes —
# which is the correct behaviour and why this reports what it untagged
# rather than claiming an amount of disk freed.
if docker rmi "${IMAGE}:${tag}" >/dev/null 2>&1; then
say " removed ${tag}"
else
# Said out loud, like its published counterpart. A container still using
# the image is the usual cause and it is not an error worth stopping for.
say " kept ${tag} (still in use, or already gone)"
fi
done
}
prune() {
[ -z "$NO_PRUNE" ] || { say "prune: skipped (--no-prune)."; return 0; }
if [ -z "${FORGEJO_REGISTRY:-}" ] || [ -z "${FORGEJO_REGISTRY_TOKEN:-}" ]; then
say "prune: skipped — no registry credentials."
return 0
fi
local owner="${IMAGE#*/}"; owner="${owner%%/*}"
local name="${IMAGE##*/}"
local api="https://${FORGEJO_REGISTRY}/api/v1/packages/${owner}/container/${name}"
# What is actually running. Not an optimisation — see the header.
local deployed
deployed=$(deployed_version)
if [ -z "$deployed" ]; then
say "prune: SKIPPED — could not read the deployed version from"
say " ${DEPLOY_HOST}/${DEPLOY_CONTAINER}. Refusing to delete anything"
say " without knowing which image production is running."
return 0
fi
local versions
versions=$(curl -sS --max-time 20 -u "${FORGEJO_REGISTRY_USER}:${FORGEJO_REGISTRY_TOKEN}" \
"https://${FORGEJO_REGISTRY}/api/v1/packages/${owner}?type=container&q=${name}" 2>/dev/null \
| PKG="$name" node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{
console.log(JSON.parse(s).filter(p => p.name === process.env.PKG).map(p => p.version).join("\n"))
}catch{process.exit(1)}})' 2>/dev/null)
if [ -z "$versions" ]; then
say "prune: skipped — could not list published versions."
return 0
fi
# Semver order, newest last. `latest` is a moving pointer rather than a
# release and is never a candidate; it is filtered before anything is counted
# so it cannot occupy one of the kept slots either.
local candidates
candidates=$(printf '%s\n' "$versions" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V)
local total keep_from doomed
total=$(printf '%s\n' "$candidates" | grep -c .)
if [ "$total" -le "$KEEP" ]; then
say "prune: ${total} published versions, keeping ${KEEP} — nothing to remove."
return 0
fi
keep_from=$((total - KEEP))
doomed=$(printf '%s\n' "$candidates" | head -n "$keep_from" | grep -v "^${deployed}$" | grep -v "^${TAG}$")
local protected
protected=$(printf '%s\n' "$candidates" | head -n "$keep_from" | grep -c "^${deployed}$" || true)
if [ "$protected" -gt 0 ]; then
say "prune: keeping ${deployed} — it is what production is running, even"
say " though it is old enough to drop."
fi
if [ -z "$doomed" ]; then
say "prune: nothing to remove."
return 0
fi
say "prune: keeping the newest ${KEEP}, plus ${deployed} (deployed) and latest."
if [ -n "$DRY_RUN" ]; then
say "prune: would remove $(printf '%s\n' "$doomed" | grep -c .) older version(s):"
printf ' %s\n' $doomed >&2
return 0
fi
say "prune: removing $(printf '%s\n' "$doomed" | grep -c .) older version(s):"
local version code
for version in $doomed; do
code=$(curl -sS --max-time 30 -o /dev/null -w '%{http_code}' \
-X DELETE -u "${FORGEJO_REGISTRY_USER}:${FORGEJO_REGISTRY_TOKEN}" \
"${api}/${version}" 2>/dev/null)
case "$code" in
2*) say " removed ${version}" ;;
# Said out loud rather than counted as done. A prune that quietly failed
# looks exactly like a prune that had nothing to do.
*) say " FAILED ${version} (HTTP ${code:-no response}) — left in place" ;;
esac
done
}
# ---------------------------------------------------------------------------
# Preconditions. Everything that can be checked without side effects, checked
# before the first side effect.
# ---------------------------------------------------------------------------
command -v docker >/dev/null 2>&1 || die "docker is not on PATH."
branch=$(git symbolic-ref --quiet --short HEAD) || die "detached HEAD — check out a branch first."
[ "$branch" = "main" ] || die "on '$branch'. Releases are cut from main."
# Only the release files are required to be clean, not the whole tree. Other
# work legitimately shares this checkout, and refusing to release because an
# unrelated file is open would make the command useless exactly when it is most
# wanted. The commit at the end stages these paths explicitly for the same
# reason: whatever else is in the tree is not part of this release.
dirty=$(git diff --name-only -- "${FILES[@]}" "${PIN_FILES[@]}" 2>/dev/null;
git diff --cached --name-only -- "${FILES[@]}" "${PIN_FILES[@]}" 2>/dev/null)
if [ -n "$dirty" ]; then
say "these release files have uncommitted changes:"
printf ' %s\n' $(printf '%s\n' "$dirty" | sort -u) >&2
die "commit or discard them first — a release must start from a known version."
fi
current=$(node -p 'require("./package.json").version') || die "cannot read package.json"
# `npm version` writes the file, so ask it what the answer would be by asking it
# somewhere disposable. Cheaper and more honest than reimplementing semver here,
# and it means the validation rules are npm's rather than a second opinion.
work=$(mktemp -d) || die "cannot create a temporary directory"
trap 'rm -rf "$work"' EXIT
printf '{"name":"v","version":"%s"}\n' "$current" > "$work/package.json"
if ! next=$(cd "$work" && npm version --no-git-tag-version "$BUMP" 2>&1 | tr -d 'v\n'); then
die "npm rejected '$BUMP': $next"
fi
[ -n "$next" ] || die "could not work out the next version from '$BUMP'."
TAG="v${next}"
say "$current -> $next (image ${IMAGE}:${TAG})"
# ---------------------------------------------------------------------------
# Refuse to move a tag that is already published.
#
# Overwriting one would silently change what a running stack pulls on its next
# recreate, and the old image would still be running with no way to tell from
# the tag. A published tag is immutable by convention here; this makes it
# immutable in practice.
# ---------------------------------------------------------------------------
if [ -r "$REGISTRY_ENV" ]; then
# shellcheck disable=SC1090
. "$REGISTRY_ENV"
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 rather than passed over. "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."
fi
if [ -n "$DRY_RUN" ]; then
say "--dry-run: nothing was changed. It would have:"
# Only the ones actually present, for the reason the whole script exists:
# a "would have" line naming a file this project does not have is a claim.
present=("${FILES[@]}")
for file in "${PIN_FILES[@]}"; do [ -f "$file" ] && present+=("$file"); done
say " set version ${next} in ${present[*]}"
say " docker build --build-arg APP_VERSION=${TAG} -t ${IMAGE}:${TAG} ."
say " docker push ${IMAGE}:${TAG}"
say " git commit -m 'chore(release): ${TAG}' (post-commit then pushes)"
say ""
say "and then pruned. That part is shown for real, because it deletes:"
prune
prune_local
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."
# The pins that drifted for six releases in the original. Anchored on the image
# name so this cannot match a version string belonging to something else.
#
# A listed file that does not exist is a configuration fact, not a failure: not
# every project has a compose file. It is said out loud rather than passed over,
# because "you do not have that file" and "the rewrite matched nothing" have the
# same appearance and only one of them is fine.
BUMPED=("${FILES[@]}")
for file in "${PIN_FILES[@]}"; do
if [ ! -f "$file" ]; then
say "note: $file is listed in PIN_FILES but does not exist — skipping."
continue
fi
# Only files that already carry a pin. A README that never mentioned the
# image is not a drift risk and must not become a failure.
grep -q "${IMAGE}:v" "$file" || { say "note: $file carries no ${IMAGE} pin — skipping."; continue; }
perl -pi -e "s{\Q${IMAGE}\E:v[0-9][0-9.]*}{${IMAGE}:${TAG}}g" "$file" \
|| die "could not rewrite the pin in $file"
# Checked, not assumed. A rewrite that silently matched nothing would produce
# a release whose own guard test fails on the next commit.
grep -q "${IMAGE}:${TAG}" "$file" || die "the pin in $file did not update — refusing to build."
BUMPED+=("$file")
done
say "bumped ${BUMPED[*]}"
# ---------------------------------------------------------------------------
# Guards, before anything is built.
#
# ## Why they are here and not left to the commit hook
#
# `pre-commit` runs the typecheck and the suite, and the commit is the LAST step
# of this script — after the push. So relying on it means the gate fires once
# the image is already published and cannot be unpublished: the hook would
# refuse the commit, and the registry would be left holding a tag whose tests
# never passed, with git carrying no record of it. The first real release
# through this script did exactly that, and passed only by luck.
#
# ## A missing test database refuses the release, it does not warn about it
#
# Without TEST_DATABASE_URL the database suites skip themselves, which on a
# repository of any size is a large fraction of the tests. That is the right
# default for a laptop with no Postgres and the wrong default for cutting a
# release — and warning about it while building anyway is the worst of the
# three, because the summary above it still says the tests passed.
# ---------------------------------------------------------------------------
if [ -z "${SKIP_GUARDS:-}" ]; then
say "typecheck…"
if ! npx tsc --noEmit; then
say "typecheck failed. The bump is in your working tree; nothing was built,"
say " published or committed."
exit 1
fi
# A warning is not a gate.
#
# This printed three lines saying half the suite would skip and then cut the
# release anyway. A gate that reports "tests passed" for a run which never
# touched the database is worse than no gate, because it is trusted — and the
# project this script came from shipped every release that way for months
# before anybody added up the numbers.
#
# So: use what is set, otherwise adopt a local test database if one is
# listening, and refuse if neither. The escape hatch stays for the machine
# with no database — which is what the warning was protecting — but it has to
# be asked for by name rather than being the default.
if [ -z "${TEST_DATABASE_URL:-}" ]; then
if [ -n "${RELEASE_TEST_DB_PORT:-}" ] \
&& (exec 3<>/dev/tcp/127.0.0.1/"${RELEASE_TEST_DB_PORT}") 2>/dev/null; then
exec 3<&- 2>/dev/null || true
export TEST_DATABASE_URL="${RELEASE_TEST_DB_URL:?set RELEASE_TEST_DB_URL beside RELEASE_TEST_DB_PORT}"
say "TEST_DATABASE_URL was unset; using the local test database on ${RELEASE_TEST_DB_PORT}."
elif [ -n "${RELEASE_ALLOW_SKIPPED_TESTS:-}" ]; then
say "WARNING: no test database, and RELEASE_ALLOW_SKIPPED_TESTS is set."
say " The database suites will SKIP. You are cutting a release"
say " that has not been fully tested."
else
die "no test database, so the database suites would silently skip.
Set TEST_DATABASE_URL, or set RELEASE_TEST_DB_PORT and RELEASE_TEST_DB_URL
so this script can find the local one, or pass RELEASE_ALLOW_SKIPPED_TESTS=1
to cut a release knowing it is half-tested."
fi
fi
say "tests…"
if ! npx vitest run --reporter=dot; then
say "tests failed. The bump is in your working tree; nothing was built,"
say " published or committed."
exit 1
fi
else
say "SKIP_GUARDS set — typecheck and tests did NOT run before this release."
fi
# ---------------------------------------------------------------------------
# Build, then push. Nothing is committed until both have succeeded.
# ---------------------------------------------------------------------------
say "building ${IMAGE}:${TAG}"
if ! docker build \
--build-arg "NEXT_PUBLIC_SITE_URL=${SITE_URL}" \
--build-arg "APP_VERSION=${TAG}" \
-t "${IMAGE}:${TAG}" . ; then
say "build failed. The version bump is in your working tree and NOTHING was"
say " published or committed. Fix the build and run this again, or"
say " 'git checkout -- ${BUMPED[*]}' to undo the bump."
exit 1
fi
# Verify before trusting it — the same move a backup script makes with `pg_restore
# --list` before renaming a dump into place. The Dockerfile threads APP_VERSION
# through two stage-scoped ARGs and an ENV; drop any one of them and the build
# still succeeds, the tests still pass, and the only symptom is the settings
# screen reading "running from source" in production. Ask the image directly
# rather than trusting that the wiring is still there.
#
# `docker run` with a command overrides CMD, so this starts no server and needs
# no database.
say "verifying the image reports ${TAG}"
baked=$(docker run --rm --entrypoint printenv "${IMAGE}:${TAG}" APP_VERSION 2>/dev/null | tr -d '\r\n')
if [ "$baked" != "$TAG" ]; 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 reports '${baked:-nothing}' but should report ${TAG}."
say " Check ARG/ENV APP_VERSION in the runner stage of the Dockerfile."
say " Nothing was published or committed; the local image 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 and run this again."
exit 1
fi
# ---------------------------------------------------------------------------
# Commit last, by explicit path.
# ---------------------------------------------------------------------------
git add -- "${BUMPED[@]}" || die "git add failed after a successful push — commit ${BUMPED[*]} by hand."
# pre-commit runs the typecheck, and the suite when a .ts/.tsx is staged. None
# of these paths are, so this gets the typecheck only — which is what catches a
# package.json this script has mangled.
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 ${BUMPED[*]} by hand."
exit 1
fi
say "released ${TAG}."
# ---------------------------------------------------------------------------
# Prune old published versions.
#
# ## Last, and deliberately so
#
# The release is complete by this point — image published, files bumped, commit
# made. Everything below is housekeeping, and housekeeping must never be able to
# fail a release that already succeeded. Nothing here exits non-zero.
#
# ## It refuses to run blind
#
# Deleting a published version is irreversible and the registry is the only copy
# — there is no `git revert` for a pushed image. The one that actually matters
# is the version PRODUCTION IS RUNNING, and that is routinely an old tag: at the
# time this was written the stack pinned v0.54.0 while v0.54.3 was the newest,
# because deploying is a separate manual step. A newest-N rule with no
# knowledge of that would eventually delete the image the running container was
# created from, and the failure surfaces later — at the next recreate, when the
# stack cannot pull the tag it pins and the site does not come back.
#
# So the deployed tag is looked up, and if it cannot be determined the prune is
# skipped entirely. "I could not check" is not "there is nothing to protect",
# and only one of those is safe to act on — the same rule the published-tag
# check above follows.
#
# ## By version name, not by digest
#
# The v2 API deletes a manifest by digest, and two tags can point at the same
# digest — `latest` here does. A digest delete would take both and the second
# one would be silent. Forgejo's package API deletes the version that was named,
# which is the only thing this ever wants to do.
# ---------------------------------------------------------------------------
prune
prune_local
say "note: this published an image. It did not deploy it — whatever runs the"
say " container still pins its own tag and that edit is a separate step."