#!/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|] [--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."