feat(deploy): releases you can pin to, and a version that tells the truth

VERSION at the repository root is the release number and the only place it is
written down — a file rather than `git describe`, because a source archive, a
shallow CI checkout and a container built without .git have no tags to
describe. Cutting a release is editing that file and merging it; CI creates the
annotated v<VERSION> tag once green, only on the commit that raised the number,
and never moves it. Servers are pinned to these.

Two modes, and the difference is the point. RELEASE=v1.0.0 pins a server to an
immutable tag, checked out detached: it moves when someone decides it moves.
BRANCH=main follows the edge, where CI tags a commit only after it is pushed.
The mode is remembered, so re-running the updater on a pinned box does not
quietly walk it back onto main.

Going backwards is refused, by commit ancestry rather than by comparing version
strings — a tag can be cut from anywhere, and only ancestry says whether history
is going back. The schema has already moved forward by then, and the migrations
needed to reverse it are not in the older checkout at all.

What the console reports comes from a manifest written atomically after every
step succeeded, never from live git: git says what the files are, not whether
the deployment came up. So a failed update keeps reporting the version that is
actually serving — including its version number, not the newer one the checkout
has already moved to. A deployment pinned to the tag reads "1.0.0 (abc1234)";
anything else reads "1.0.0-dev (abc1234) · main", because every commit after the
tag still carries VERSION=1.0.0 and is not that release. Reporting it as one is
how a bug gets filed against the wrong code.

Codex reviewed the design before it was written and the result six times after.
Its findings, all real: the version had to come from the manifest too, not just
the commit; branch names need JSON escaping or the manifest silently vanishes; a
`case` glob does not anchor and accepted 1x.2y.3garbage; pinning to the commit a
server already sits on skipped recording the mode, and then skipped detaching;
a manifest that failed to write would never be repaired; an unparseable
timestamp would 500 every admin page; and an empty tag list read as a
connectivity failure.

466 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 15:21:38 +02:00
parent c119e8d96e
commit e9240c1324
12 changed files with 615 additions and 25 deletions

View File

@ -75,3 +75,44 @@ jobs:
tag="tested-$(date -u +%Y%m%d-%H%M)-$(git rev-parse --short HEAD)"
git tag "$tag"
git push origin "$tag"
# A release is cut by editing VERSION and merging it — nothing else. The
# tag is created here, only after the suite is green, and only if it does
# not already exist. Never moved: servers are pinned to these, and a tag
# that changes underneath them means two machines claiming one version.
- name: Tag the release when VERSION changed
run: |
version="$(tr -d ' \n\r' < VERSION)"
# Anchored, because a `case` glob does not anchor: `[0-9]*.[0-9]*`
# happily accepts 1x.2y.3garbage and would tag it.
printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' \
|| { echo "VERSION is not MAJOR.MINOR.PATCH: '$version'" >&2; exit 1; }
# Only the commit that RAISED the version is the release. Without
# this, any later green push finds the tag missing and claims it —
# and the tag would then name code the bump never described. Runs
# overlap and finish out of order; that is enough for it to happen.
previous="$(git show 'HEAD^:VERSION' 2>/dev/null | tr -d ' \n\r' || true)"
if [ "$previous" = "$version" ]; then
echo "::notice::VERSION is unchanged ($version) — nothing to release."
exit 0
fi
tag="v${version}"
git fetch --tags --force origin
if existing="$(git rev-parse -q --verify "refs/tags/${tag}^{commit}")"; then
# Already released. The invariant is that the tag points at the
# commit VERSION was raised in — if a later commit still carries
# that number, that is fine, but the tag must not be re-pointed.
if [ "$existing" != "$(git rev-parse HEAD)" ]; then
echo "::notice::${tag} already exists at ${existing}; leaving it alone."
fi
exit 0
fi
git config user.name "CluPilot CI"
git config user.email "ci@clupilot.local"
git tag -a "$tag" -m "CluPilot ${version}"
git push origin "$tag"
echo "::notice::Released ${tag}"

1
VERSION Normal file
View File

@ -0,0 +1 @@
1.0.0

View File

@ -0,0 +1,126 @@
<?php
namespace App\Services\Deployment;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Throwable;
/**
* What is actually running here.
*
* Read from the manifest the deployment writes when every step has SUCCEEDED
* not from the checkout, and not from live git. The checkout moves before the
* migrations run; git tells you what the files say, not whether the deployment
* finished. An operator asking "which version is this?" during a failed update
* needs the answer "still the old one", and only a manifest written at the very
* end can give it.
*
* Deliberately not cached in config either: `optimize` runs BEFORE the manifest
* is written, so a cached copy would be one deployment behind forever.
*/
final readonly class Release
{
private function __construct(
/** The release number from VERSION, e.g. "1.0.0". */
public string $version,
/** The exact commit deployed, or null if nothing has deployed yet. */
public ?string $commit,
/** What it was deployed from: "refs/tags/v1.0.0" or "main". */
public ?string $source,
/** "release" — pinned to a tag — or "branch", following a line. */
public string $mode,
public ?Carbon $deployedAt,
/** True when the deployed commit is exactly the one VERSION is tagged at. */
public bool $isTaggedRelease,
) {}
public static function current(): self
{
$manifest = self::manifest();
// The version that DEPLOYED, not the one the checkout now claims. An
// update moves the checkout before it migrates, so a run that fails in
// between leaves a newer VERSION on disk than is actually serving —
// and pairing that number with the old commit is worse than either
// alone. Config is the fallback only until something has deployed.
$version = isset($manifest['version']) && $manifest['version'] !== ''
? (string) $manifest['version']
: (string) config('app.version', '0.0.0');
return new self(
version: $version,
commit: isset($manifest['commit']) ? (string) $manifest['commit'] : null,
source: isset($manifest['source']) ? (string) $manifest['source'] : null,
mode: (string) ($manifest['mode'] ?? 'branch'),
deployedAt: self::timestamp($manifest['deployed_at'] ?? null),
// Only a deployment pinned to the tag is that release. Every commit
// after v1.0.0 still carries VERSION=1.0.0 and is not 1.0.0.
isTaggedRelease: isset($manifest['source'])
&& $manifest['source'] === "refs/tags/v{$version}",
);
}
/** @return array<string, mixed> */
private static function manifest(): array
{
$path = storage_path('app/deployment.json');
if (! File::exists($path)) {
return [];
}
return (array) (json_decode((string) File::get($path), true) ?: []);
}
/**
* A timestamp that cannot take the console down.
*
* The manifest is written by a shell script during a deployment, and every
* other field here is already treated as possibly corrupt. An unparseable
* date throwing out of a layout partial would 500 every admin page over a
* line of small print in the sidebar.
*/
private static function timestamp(mixed $value): ?Carbon
{
if (! is_string($value) || $value === '') {
return null;
}
try {
return Carbon::parse($value);
} catch (Throwable) {
return null;
}
}
/** The short commit, for a footer that has no room for forty characters. */
public function shortCommit(): ?string
{
return $this->commit !== null ? substr($this->commit, 0, 7) : null;
}
/**
* One line, honest about what it is.
*
* "1.0.0 (abc1234)" only for a deployment pinned to that tag. Anything else
* is somewhere after the release and says so reporting a main deployment
* as a clean 1.0.0 is how a bug report ends up filed against the wrong code.
*/
public function label(): string
{
$commit = $this->shortCommit();
if ($this->isTaggedRelease) {
return $commit !== null ? "{$this->version} ({$commit})" : $this->version;
}
$line = $this->source !== null && $this->source !== ''
? ' · '.$this->source
: '';
return $commit !== null
? "{$this->version}-dev ({$commit}){$line}"
: "{$this->version}-dev{$line}";
}
}

View File

@ -15,6 +15,25 @@ return [
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Release Version
|--------------------------------------------------------------------------
|
| Read from the VERSION file at the repository root, which is the single
| source of truth for the release number and is what CI tags as `v<VERSION>`.
| A file rather than `git describe`, because that needs a full clone with
| tags fetched which a source archive, a shallow CI checkout and a
| container built without .git all lack.
|
| Safe to cache: it only changes when the checkout does, and the deployment
| rebuilds the config cache. What must NOT be cached is which commit is
| actually deployed see App\Services\Deployment\Release.
|
*/
'version' => trim(@file_get_contents(base_path('VERSION')) ?: '0.0.0'),
/*
|--------------------------------------------------------------------------
| Application Environment

View File

@ -20,6 +20,11 @@ set -euo pipefail
REPO_URL="${REPO_URL:-https://git.bave.dev/boban/CluPilotCloud.git}"
INSTALL_DIR="${INSTALL_DIR:-/opt/clupilot}"
BRANCH="${BRANCH:-main}"
# RELEASE=v1.0.0 pins the server to that tag instead of following a branch.
# What a production install should use: a tag is immutable, so the machine moves
# only when someone decides it moves. Without it, main is followed — the edge,
# where CI has not necessarily finished, or has failed.
RELEASE="${RELEASE:-}"
# The application never runs as root. This account owns the checkout and is the
# one in the docker group; the installer itself needs root only for apt.
APP_USER="${APP_USER:-clupilot}"
@ -148,11 +153,19 @@ as_app() { runuser -u "$APP_USER" -- "$@"; }
install -d -o "$APP_USER" -g "$APP_USER" -m 0755 "$(dirname "$INSTALL_DIR")"
if [[ -d "$INSTALL_DIR/.git" ]]; then
# An existing checkout is not this script's business beyond bringing it to
# the requested ref — deploy/update.sh is the tool for moving a running
# installation, and it knows about maintenance mode and migrations.
log "Updating existing checkout in $INSTALL_DIR"
chown -R "$APP_USER:$APP_USER" "$INSTALL_DIR"
as_app git -C "$INSTALL_DIR" fetch --quiet origin "$BRANCH"
as_app git -C "$INSTALL_DIR" checkout --quiet "$BRANCH"
as_app git -C "$INSTALL_DIR" pull --quiet --ff-only origin "$BRANCH"
if [[ -n "$RELEASE" ]]; then
as_app git -C "$INSTALL_DIR" fetch --quiet --tags --force origin
as_app git -C "$INSTALL_DIR" checkout --quiet --detach "refs/tags/${RELEASE}"
else
as_app git -C "$INSTALL_DIR" fetch --quiet origin "$BRANCH"
as_app git -C "$INSTALL_DIR" checkout --quiet "$BRANCH"
as_app git -C "$INSTALL_DIR" pull --quiet --ff-only origin "$BRANCH"
fi
else
log "Cloning into $INSTALL_DIR"
install -d -o "$APP_USER" -g "$APP_USER" -m 0750 "$INSTALL_DIR"
@ -166,24 +179,45 @@ else
chown "$APP_USER" "$askpass"
trap 'rm -f "$askpass"' EXIT
# Ask what is actually there before cloning. Without this, a BRANCH that
# does not exist fails inside git with "Remote branch not found", which
# reads like a broken token or an unreachable server — and the branch is
# the one thing here that is routinely wrong.
remote_heads="$(GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0 \
as_app git ls-remote --heads "${REPO_URL/https:\/\//https://oauth2@}" 2>/dev/null \
| sed 's|.*refs/heads/||')" \
|| die "Could not reach ${REPO_URL}. Check the server and the token."
# Ask what is actually there before cloning. Without this, a ref that does
# not exist fails inside git with "Remote branch not found", which reads
# like a broken token or an unreachable server — and the ref is the one
# thing here that is routinely wrong.
auth_url="${REPO_URL/https:\/\//https://oauth2@}"
if ! grep -qxF "$BRANCH" <<<"$remote_heads"; then
die "The repository has no branch '${BRANCH}'. It has:
$(sed 's/^/ /' <<<"$remote_heads")
Pass the one you want: BRANCH=<name> bash install.sh"
if [[ -n "$RELEASE" ]]; then
wanted="$RELEASE"
# `|| true` on the grep: a repository that is reachable but has no
# releases yet makes grep exit 1, and under pipefail that would be
# reported as "could not reach the server" — sending someone to check a
# token that is perfectly fine. An empty list is an answer.
available="$(GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0 \
as_app git ls-remote --tags "$auth_url" 2>/dev/null \
| sed 's|.*refs/tags/||; s|\^{}$||' | { grep -E '^v' || true; } | sort -u)" \
|| die "Could not reach ${REPO_URL}. Check the server and the token."
kind="release tag"
hint="RELEASE=<tag>"
else
wanted="$BRANCH"
available="$(GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0 \
as_app git ls-remote --heads "$auth_url" 2>/dev/null \
| sed 's|.*refs/heads/||')" \
|| die "Could not reach ${REPO_URL}. Check the server and the token."
kind="branch"
hint="BRANCH=<name>"
fi
if ! grep -qxF "$wanted" <<<"$available"; then
die "The repository has no ${kind} '${wanted}'. It has:
$(sed 's/^/ /' <<<"${available:-(none)}")
Pass the one you want: ${hint} bash install.sh"
fi
# --branch takes a tag too, and leaves the checkout detached on it, which is
# exactly what a pinned server should be.
GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0 \
as_app git clone --quiet --branch "$BRANCH" "${REPO_URL/https:\/\//https://oauth2@}" "$INSTALL_DIR"
as_app git clone --quiet --branch "$wanted" "$auth_url" "$INSTALL_DIR"
rm -f "$askpass"
trap - EXIT
@ -312,9 +346,23 @@ compose exec -T app php artisan clupilot:create-admin \
--email="$ADMIN_EMAIL" --name="$ADMIN_NAME" --password="$ADMIN_PASSWORD"
# Record what has been deployed: update.sh compares against this to tell a
# finished deployment from one that died halfway.
# finished deployment from one that died halfway, and the console reports the
# manifest rather than live git — git says what the files are, not whether the
# deployment came up.
# shellcheck source=deploy/lib/release.sh
. "$INSTALL_DIR/deploy/lib/release.sh"
as_app mkdir -p storage/app
as_app git rev-parse HEAD > storage/app/deployed-commit
if [[ -n "$RELEASE" ]]; then
DEPLOY_MODE="release"; DEPLOY_SOURCE="refs/tags/${RELEASE}"
else
DEPLOY_MODE="branch"; DEPLOY_SOURCE="$BRANCH"
fi
release_remember "$DEPLOY_MODE" "$DEPLOY_SOURCE"
release_write_manifest "$(as_app git rev-parse HEAD)" "$DEPLOY_SOURCE" "$DEPLOY_MODE"
chown -R "$APP_USER:$APP_USER" "$INSTALL_DIR"
# ── 8. Firewall ──────────────────────────────────────────────────────────────

88
deploy/lib/release.sh Normal file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env bash
#
# Shared release helpers for install.sh and update.sh.
#
# Two deployment modes, and the difference matters:
#
# release — pinned to an immutable tag, checked out detached. What a
# production server should be on: it moves only when someone
# decides it moves.
# branch — following the head of a line, usually main. The edge. CI tags a
# commit AFTER it is pushed, so origin/main is briefly, and after a
# failure permanently, code nobody has verified.
#
# The mode is persisted, because update.sh cannot infer it: a detached checkout
# and a branch checkout need entirely different git commands, and guessing wrong
# either fails loudly or silently drags a pinned server onto the edge.
MODE_FILE="storage/app/deploy-mode"
MANIFEST_FILE="storage/app/deployment.json"
# The release number the checkout claims to be.
release_version() { tr -d ' \n\r' < VERSION 2>/dev/null || echo '0.0.0'; }
# release_mode — "release" or "branch"; defaults to branch for installs that
# predate this file.
release_mode() { cat "$MODE_FILE" 2>/dev/null || echo 'branch'; }
release_source() { cat "${MODE_FILE}.source" 2>/dev/null || echo ''; }
# The commit the manifest claims is deployed. Read with sed rather than a JSON
# parser so this has no dependency the installer does not already guarantee.
release_manifest_commit() {
sed -n 's/.*"commit"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST_FILE" 2>/dev/null | head -1
}
release_remember() { # release_remember MODE SOURCE
mkdir -p "$(dirname "$MODE_FILE")"
printf '%s' "$1" > "$MODE_FILE"
printf '%s' "$2" > "${MODE_FILE}.source"
}
# release_would_go_backwards BASE TARGET — true when TARGET is an ancestor of
# BASE, i.e. this is a move back in history.
#
# Ancestry, not version strings: SemVer order and git history are different
# things, and a tag can be cut from anywhere. Ancestry is what actually says
# whether the code is going backwards.
release_would_go_backwards() {
local base="$1" target="$2"
[[ "$base" == "$target" ]] && return 1
git merge-base --is-ancestor "$target" "$base" 2>/dev/null
}
# release_write_manifest COMMIT SOURCE MODE
#
# Written only once every step has succeeded, and written atomically: a
# half-written manifest read by the console would report a version that never
# ran. The console reads this rather than live git, because git says what the
# files are, not whether the deployment finished.
release_write_manifest() {
local commit="$1" source="$2" mode="$3" tmp
mkdir -p "$(dirname "$MANIFEST_FILE")"
tmp="$(mktemp "${MANIFEST_FILE}.XXXXXX")"
printf '{\n "version": "%s",\n "commit": "%s",\n "source": "%s",\n "mode": "%s",\n "deployed_at": "%s"\n}\n' \
"$(json_escape "$(release_version)")" \
"$(json_escape "$commit")" \
"$(json_escape "$source")" \
"$(json_escape "$mode")" \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp"
chmod 644 "$tmp"
mv -f "$tmp" "$MANIFEST_FILE"
}
# json_escape STRING — a branch name is chosen by whoever made the branch, and
# a quote or a backslash in one would produce a document the console reads as
# absent: it would lose the deployment metadata after a deployment that
# otherwise went perfectly. No jq dependency, because this has to work on a
# server installed before jq was ever on the package list.
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
# Control characters cannot occur in a git ref and cannot be represented
# raw; dropping them beats emitting a broken document.
printf '%s' "$s" | tr -d '\000-\037'
}

View File

@ -14,7 +14,14 @@
set -euo pipefail
cd "$(dirname "$0")/.."
# shellcheck source=deploy/lib/release.sh
. deploy/lib/release.sh
BRANCH="${BRANCH:-main}"
# RELEASE=v1.2.0 moves a pinned server to that tag. Without it, the mode the
# server was installed in is kept — a production box pinned to a release does
# not quietly start following main because someone re-ran the updater.
RELEASE="${RELEASE:-}"
# Never as root: the checkout belongs to the service account, and running this
# as root would leave root-owned files behind that the app cannot write.
@ -42,18 +49,83 @@ finish() {
}
trap finish EXIT
log "Fetching $BRANCH"
git fetch --quiet origin "$BRANCH"
# Which line this server follows, and what it is being moved to.
mode="$(release_mode)"
[[ -n "$RELEASE" ]] && mode="release"
if [[ "$mode" == "release" ]]; then
# A pinned server without an explicit RELEASE has nothing to do: staying on
# the tag is the whole point of being pinned.
if [[ -z "$RELEASE" ]]; then
RELEASE="$(release_source)"
RELEASE="${RELEASE#refs/tags/}"
[[ -n "$RELEASE" ]] || { echo "Pinned to a release, but no tag recorded. Pass RELEASE=vX.Y.Z." >&2; exit 1; }
fi
log "Fetching release $RELEASE"
git fetch --quiet --tags --force origin
git rev-parse -q --verify "refs/tags/${RELEASE}^{commit}" >/dev/null \
|| { echo "No such release tag: ${RELEASE}" >&2; exit 1; }
target="$(git rev-parse "refs/tags/${RELEASE}^{commit}")"
source_ref="refs/tags/${RELEASE}"
else
log "Fetching $BRANCH"
git fetch --quiet origin "$BRANCH"
target="$(git rev-parse "origin/$BRANCH")"
source_ref="$BRANCH"
fi
before="$(git rev-parse HEAD)"
target="$(git rev-parse "origin/$BRANCH")"
# Backwards is not an update. The database has already been migrated forward,
# and older code against a newer schema is the one failure this script exists to
# prevent — with the added cruelty that the migrations needed to roll back are
# not in the older checkout at all. A genuine downgrade means restoring the
# pre-upgrade database snapshot first, deliberately, by hand.
if release_would_go_backwards "$before" "$target"; then
cat >&2 <<BACKWARDS
Refusing to move backwards: ${source_ref} is behind what is deployed.
deployed $(git rev-parse --short "$before")
requested $(git rev-parse --short "$target")
The schema has already moved forward, and older code against a newer schema is
what maintenance mode exists to avoid. To go back: restore the database
snapshot taken before the upgrade, then install the older release.
BACKWARDS
exit 1
fi
# What was last deployed successfully — not what is merely checked out. A run
# that died halfway leaves these different, and the next run finishes the job
# instead of declaring "already up to date".
deployed="$(cat "$STATE_FILE" 2>/dev/null || echo '')"
if [[ "$before" == "$target" && "$deployed" == "$target" ]]; then
log "Already up to date ($(git rev-parse --short HEAD))"
# Nothing to deploy, but possibly something to decide: pinning a server to
# a release that happens to be the commit it is already on is a real and
# sensible thing to do. Taking this exit without recording it would leave
# the machine looking pinned while the next plain update quietly walks it
# back onto the branch.
# The manifest is checked too, not just the mode: if writing it failed at
# the end of the last run — a full disk is enough — nothing else would ever
# repair it, and the console would report the previous release forever
# while every later update declared itself already done.
if [[ "$(release_mode)" != "$mode" || "$(release_source)" != "$source_ref" \
|| "$(release_manifest_commit)" != "$before" ]]; then
log "Already on this commit — recording it as $mode ($source_ref)"
# And actually pin it. A checkout still attached to the branch is not
# pinned, whatever the manifest claims: the next thing that touches git
# moves it, and the machine drifts off the release it is supposed to be
# nailed to.
if [[ "$mode" == "release" ]]; then
git checkout --quiet --detach "$target"
fi
release_remember "$mode" "$source_ref"
release_write_manifest "$before" "$source_ref" "$mode"
else
log "Already up to date ($(git rev-parse --short HEAD), ${source_ref})"
fi
exit 0
fi
@ -77,8 +149,16 @@ log "Enabling maintenance mode"
in_app php artisan down --retry=60 >/dev/null || warn "Could not enable maintenance mode (continuing)"
down=1
log "Checking out $BRANCH"
git merge --quiet --ff-only "origin/$BRANCH"
if [[ "$mode" == "release" ]]; then
# Detached on purpose: a release is a fixed point, not a line to follow.
# `git merge --ff-only` would be meaningless here, and on a detached HEAD it
# is how a pinned server silently rejoins main.
log "Checking out $source_ref"
git checkout --quiet --detach "$target"
else
log "Checking out $BRANCH"
git merge --quiet --ff-only "origin/$BRANCH"
fi
after="$(git rev-parse HEAD)"
# The image is only rebuilt when its definition changed — minutes versus seconds.
@ -133,4 +213,11 @@ in_app php artisan up >/dev/null
down=0
printf '%s' "$after" > "$STATE_FILE"
log "Done — now on $(git rev-parse --short HEAD)"
# Only now: the manifest is what the console reports, and it must mean "this
# came up". Written after `artisan up`, atomically, so a reader never catches it
# half-finished.
release_remember "$mode" "$source_ref"
release_write_manifest "$after" "$source_ref" "$mode"
log "Done — $(release_version) on $(git rev-parse --short HEAD) (${source_ref})"

View File

@ -58,6 +58,52 @@ are restarted at the end — they are long-running PHP processes and keep the ol
classes in memory otherwise. That one bit us during development: a fixed job
kept failing until the worker was restarted.
## Versions and releases
`VERSION` at the repository root is the release number, and it is the only place
it is written down. A file rather than `git describe`, because a source archive,
a shallow CI checkout and a container built without `.git` all have no tags to
describe.
Cutting a release is editing that file and merging it to `main`. CI creates the
annotated tag `v<VERSION>` once the suite is green, and never moves it
afterwards — servers are pinned to these, and a tag that shifts underneath them
means two machines claiming to be the same version. The older `tested-…` tags
are a separate thing: a CI marker on every green commit, not a release.
**Two ways to run a server, and the difference matters.**
| | pinned to a release | following a branch |
|---|---|---|
| install | `RELEASE=v1.0.0 bash install.sh` | `BRANCH=main bash install.sh` |
| update | `RELEASE=v1.1.0 bash deploy/update.sh` | `bash deploy/update.sh` |
| moves when | you say so | someone pushes |
Production belongs on a release. `main` is the edge: CI tags a commit *after* it
is pushed, so `origin/main` is briefly — and after a failed run permanently —
code nobody has verified.
The mode is remembered. Re-running the updater on a pinned server does not
quietly drag it onto `main`; moving it takes an explicit `RELEASE=`.
**Going backwards is refused.** The schema has already migrated forward, and old
code against a new schema is exactly what maintenance mode exists to prevent —
with the extra cruelty that the migrations needed to roll back are not in the
older checkout at all. A real downgrade means restoring the database snapshot
taken before the upgrade, deliberately, and then installing the older release.
Detection is by commit ancestry rather than by comparing version strings: a tag
can be cut from anywhere, and only ancestry says whether history is going back.
**What the console shows** comes from `storage/app/deployment.json`, written
atomically after every deployment step has succeeded — not from live git. Git
tells you what the files say, not whether the deployment came up. So during a
failed update the console still reports the version that is actually serving.
A deployment pinned to the tag reads `1.0.0 (abc1234)`. Anything else reads
`1.0.0-dev (abc1234) · main`, because every commit after the tag still carries
`VERSION=1.0.0` and is not that release. Reporting it as one is how a bug gets
filed against the wrong code.
## What the installer deliberately leaves to you
- **DNS and TLS.** Put a reverse proxy in front (Zoraxy, Caddy, nginx). The

View File

@ -19,6 +19,8 @@ return [
'settings' => 'Einstellungen',
],
'version_hint' => 'Laufende Version und Commit — das, was zuletzt erfolgreich ausgerollt wurde.',
'overview_title' => 'Fleet-Übersicht',
'overview_sub' => 'Zustand der gesamten Plattform auf einen Blick.',
'systems_ok' => 'Alle Systeme normal',

View File

@ -19,6 +19,8 @@ return [
'settings' => 'Settings',
],
'version_hint' => 'Running version and commit — what last deployed successfully.',
'overview_title' => 'Fleet overview',
'overview_sub' => 'The whole platform at a glance.',
'systems_ok' => 'All systems normal',

View File

@ -52,6 +52,11 @@
<x-slot:icon><x-ui.icon name="settings" /></x-slot:icon>
{{ __('admin.nav.settings') }}
</x-ui.nav-item>
{{-- What is actually deployed, so a bug report names the right
code. `-dev` means somewhere after the release, not on it. --}}
@php $release = App\Services\Deployment\Release::current(); @endphp
<p class="px-3 pt-2 font-mono text-[11px] leading-tight text-faint"
title="{{ __('admin.version_hint') }}">{{ $release->label() }}</p>
</div>
</aside>

View File

@ -0,0 +1,125 @@
<?php
use App\Models\User;
use App\Services\Deployment\Release;
use Illuminate\Support\Facades\File;
/**
* What the console says it is running. The point of the distinction is that a
* bug report has to name the right code: a deployment somewhere after 1.0.0 is
* not 1.0.0, and reporting it as one sends someone reading the wrong source.
*/
function writeManifest(array $manifest): void
{
File::ensureDirectoryExists(storage_path('app'));
File::put(storage_path('app/deployment.json'), json_encode($manifest));
}
afterEach(fn () => File::delete(storage_path('app/deployment.json')));
it('reads the release number from the VERSION file', function () {
// One source of truth, and it is a file — a source archive, a shallow CI
// checkout and a container built without .git have no tags to describe.
expect(config('app.version'))->toBe(trim(File::get(base_path('VERSION'))))
->and(config('app.version'))->toMatch('/^\d+\.\d+\.\d+$/');
});
it('reports a pinned release as that release', function () {
writeManifest([
'version' => config('app.version'),
'commit' => 'abc1234567890',
'source' => 'refs/tags/v'.config('app.version'),
'mode' => 'release',
'deployed_at' => now()->toIso8601String(),
]);
$release = Release::current();
expect($release->isTaggedRelease)->toBeTrue()
->and($release->label())->toBe(config('app.version').' (abc1234)');
});
it('does not call a deployment after the release by the release\'s name', function () {
// Every commit after v1.0.0 still carries VERSION=1.0.0 and is not 1.0.0.
writeManifest([
'commit' => 'def4567890123',
'source' => 'main',
'mode' => 'branch',
'deployed_at' => now()->toIso8601String(),
]);
$release = Release::current();
expect($release->isTaggedRelease)->toBeFalse()
->and($release->label())->toBe(config('app.version').'-dev (def4567) · main');
});
it('keeps reporting the deployed version when an update fails halfway', function () {
// The checkout moves before the migrations run, so a run that dies in
// between leaves a newer VERSION on disk than is actually serving. Pairing
// that number with the still-deployed commit would describe a build that
// never existed.
writeManifest([
'version' => '0.9.0',
'commit' => 'aaa1111222333',
'source' => 'refs/tags/v0.9.0',
'mode' => 'release',
'deployed_at' => now()->subDay()->toIso8601String(),
]);
$release = Release::current();
expect($release->version)->toBe('0.9.0')
->and($release->version)->not->toBe(config('app.version'))
// Still a clean release label: 0.9.0 is genuinely what is running.
->and($release->isTaggedRelease)->toBeTrue()
->and($release->label())->toBe('0.9.0 (aaa1111)');
});
it('says something sensible before anything has been deployed', function () {
File::delete(storage_path('app/deployment.json'));
expect(Release::current()->commit)->toBeNull()
->and(Release::current()->label())->toBe(config('app.version').'-dev');
});
it('does not take the console down over an unparseable timestamp', function () {
writeManifest([
'version' => '1.0.0',
'commit' => 'abc1234567890',
'source' => 'main',
'mode' => 'branch',
'deployed_at' => 'not-a-date',
]);
// A line of small print in the sidebar must not 500 every admin page.
expect(Release::current()->deployedAt)->toBeNull()
->and(Release::current()->label())->toBe('1.0.0-dev (abc1234) · main');
$this->actingAs(User::factory()->operator()->create())
->get(route('admin.overview'))
->assertOk();
});
it('survives a manifest that is not readable json', function () {
File::ensureDirectoryExists(storage_path('app'));
File::put(storage_path('app/deployment.json'), '{ half-written');
// The console reporting nothing is a nuisance; the console 500ing because
// a deployment was interrupted mid-write is an outage.
expect(Release::current()->label())->toBe(config('app.version').'-dev');
});
it('shows the running version in the console', function () {
writeManifest([
'commit' => 'abc1234567890',
'source' => 'refs/tags/v'.config('app.version'),
'mode' => 'release',
'deployed_at' => now()->toIso8601String(),
]);
$this->actingAs(User::factory()->operator()->create())
->get(route('admin.overview'))
->assertOk()
->assertSee(config('app.version').' (abc1234)');
});