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
parent
c119e8d96e
commit
e9240c1324
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
101
deploy/update.sh
101
deploy/update.sh
|
|
@ -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})"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
});
|
||||
Loading…
Reference in New Issue