diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index c953304..089ff0c 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -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}" diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/app/Services/Deployment/Release.php b/app/Services/Deployment/Release.php new file mode 100644 index 0000000..93bbe27 --- /dev/null +++ b/app/Services/Deployment/Release.php @@ -0,0 +1,126 @@ + */ + 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}"; + } +} diff --git a/config/app.php b/config/app.php index 423eed5..8136f69 100644 --- a/config/app.php +++ b/config/app.php @@ -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`. + | 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 diff --git a/deploy/install.sh b/deploy/install.sh index 991bf4c..3bdc169 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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= 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=" + 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=" 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 ────────────────────────────────────────────────────────────── diff --git a/deploy/lib/release.sh b/deploy/lib/release.sh new file mode 100644 index 0000000..6d57e6a --- /dev/null +++ b/deploy/lib/release.sh @@ -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' +} diff --git a/deploy/update.sh b/deploy/update.sh index fc4002f..991794b 100755 --- a/deploy/update.sh +++ b/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 </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})" diff --git a/docs/deployment.md b/docs/deployment.md index 2a03ef4..fe1f7dd 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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` 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 diff --git a/lang/de/admin.php b/lang/de/admin.php index bcce7d8..50da367 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -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', diff --git a/lang/en/admin.php b/lang/en/admin.php index 0b244e7..40bc643 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -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', diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index fe5c10d..9846f2f 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -52,6 +52,11 @@ {{ __('admin.nav.settings') }} + {{-- 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 +

{{ $release->label() }}

diff --git a/tests/Feature/ReleaseVersionTest.php b/tests/Feature/ReleaseVersionTest.php new file mode 100644 index 0000000..cb21842 --- /dev/null +++ b/tests/Feature/ReleaseVersionTest.php @@ -0,0 +1,125 @@ + 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)'); +});