diff --git a/VERSION b/VERSION index 3eefcb9..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index dbc7436..29029cc 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -391,10 +391,9 @@ class Settings extends Component * restarted out from under the response — so it reports that the update was * asked for, and the page shows the outcome once the agent has written it. * - * The sibling of requestCheck() below: this one applies whatever it finds, - * downtime and all, regardless of whether the last check found anything — - * that reading is a few minutes old, and the agent re-fetches before it - * decides. + * The sibling of requestCheck() below: this one applies, downtime and all — + * but only a released version above the one installed. There is no longer + * such a thing as "update to whatever is on the branch". */ public function requestUpdate(): void { @@ -404,6 +403,17 @@ class Settings extends Component return; } + // Checked here as well as in the markup. The button is disabled when + // there is nothing released to install, and the agent refuses such a + // request too — but a Livewire action is a public endpoint, and the one + // place a rule may not live is only in the disabled attribute of a + // button. Queueing a run for nothing costs a maintenance window. + if (! app(UpdateChannel::class)->state()['available']) { + $this->dispatch('notify', message: __('admin_settings.update_nothing_released')); + + return; + } + $accepted = app(UpdateChannel::class)->request($operator->email); $this->dispatch('notify', message: __($accepted diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 00690dd..394534f 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -136,6 +136,12 @@ final class UpdateChannel // date" and must not be shown as it. 'behind' => $behind, 'available' => $behind !== null && $behind > 0, + // The tag an update would install, e.g. "v1.1.0". An update is + // always to a released version now — a commit landing on main is + // not an update, and the console has nothing to say about it. + 'target_release' => $agentAlive && ! empty($status['target_release']) + ? (string) $status['target_release'] + : null, 'remote_commit' => isset($status['remote_commit']) ? (string) $status['remote_commit'] : null, 'checked_at' => $checkedAt, diff --git a/deploy/lib/release.sh b/deploy/lib/release.sh index 6d57e6a..d351c10 100644 --- a/deploy/lib/release.sh +++ b/deploy/lib/release.sh @@ -33,6 +33,15 @@ release_manifest_commit() { sed -n 's/.*"commit"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST_FILE" 2>/dev/null | head -1 } +# The version the manifest says actually DEPLOYED, which is not the same as the +# VERSION file: an update moves the checkout before it migrates, so a run that +# failed in between leaves a newer number on disk than is serving. Deciding +# "is there something newer" against the file would then offer nothing, because +# the checkout already claims to be the version it never finished installing. +release_manifest_version() { + sed -n 's/.*"version"[[: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" diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index cc086c2..16ec61b 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -196,47 +196,51 @@ BEHIND=null FETCH_ERROR='' # ── Is there anything newer? ────────────────────────────────────────────── -# Two different questions depending on what this installation follows. A pinned -# server does not care what landed on main — it moves between TAGS, and asking -# the branch would advertise updates that pressing the button cannot apply. -if [[ "$MODE" == "release" ]]; then - CURRENT_TAG="$(release_source)"; CURRENT_TAG="${CURRENT_TAG#refs/tags/}" +# +# One question, whatever this installation is checked out as: is there a TAG +# whose version is higher than the one currently deployed? +# +# It used to be two questions. A pinned server compared tags; everything else +# counted commits on its branch — so a console following main offered an update +# for every commit that landed, verified or not, and the button meant "take +# whatever is on the branch right now". Landing on main and being released are +# different events, and only the second one is a decision somebody made. +# +# Compared by VERSION, not by ancestry or by tag date: a tag can be cut from +# anywhere, and what an operator is being offered is a version number. `sort -V` +# does the comparing, so v1.10.0 correctly beats v1.9.0. +DEPLOYED_VERSION="$(release_manifest_version)" +[[ -n "$DEPLOYED_VERSION" ]] || DEPLOYED_VERSION="$(release_version)" - if git fetch --quiet --tags --force origin 2>/dev/null; then - # Newest by version order, not by tag date: v1.10.0 is newer than v1.9.0 - # and sorts earlier alphabetically. - NEWEST_TAG="$(git tag --list 'v*' --sort=-v:refname 2>/dev/null | head -1)" +# version_gt A B — true when A is strictly a higher version than B. +version_gt() { + [[ "$1" != "$2" ]] && [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" == "$1" ]] +} - if [[ -n "$NEWEST_TAG" && "$NEWEST_TAG" != "$CURRENT_TAG" ]]; then - # Count only tags actually ahead of the deployed one. - BEHIND="$(git tag --list 'v*' --sort=-v:refname 2>/dev/null \ - | awk -v cur="$CURRENT_TAG" '$0 == cur {exit} {n++} END {print n+0}')" - [[ "${BEHIND:-0}" -gt 0 ]] && TARGET_RELEASE="$NEWEST_TAG" - REMOTE_COMMIT="$(git rev-parse "refs/tags/${NEWEST_TAG}^{commit}" 2>/dev/null || echo '')" - else - BEHIND=0 - fi +if git fetch --quiet --tags --force origin 2>/dev/null; then + # Newest by version order, not by tag date. + NEWEST_TAG="$(git tag --list 'v*' --sort=-v:refname 2>/dev/null | head -1)" + NEWEST_VERSION="${NEWEST_TAG#v}" + + if [[ -n "$NEWEST_TAG" ]] && version_gt "$NEWEST_VERSION" "$DEPLOYED_VERSION"; then + # How many releases ahead, so the console can say "2 Aktualisierungen" + # rather than only that something exists. + BEHIND="$(git tag --list 'v*' --sort=-v:refname 2>/dev/null | while read -r t; do + version_gt "${t#v}" "$DEPLOYED_VERSION" && echo "$t" + done | wc -l | tr -d ' ')" + TARGET_RELEASE="$NEWEST_TAG" + REMOTE_COMMIT="$(git rev-parse "refs/tags/${NEWEST_TAG}^{commit}" 2>/dev/null || echo '')" else - # A code, not a sentence: the panel is translated and this script is - # not, so a German string here would surface in the English interface. - FETCH_ERROR='repo_unreachable' + # Includes "no tags at all yet": nothing has been released, so there is + # nothing to offer, and saying "up to date" is the truth. + BEHIND=0 fi else - if [[ "$BRANCH" == "HEAD" ]]; then - # Detached, but not pinned to a release either — nothing sane to follow. - FETCH_ERROR='detached_no_release' - elif git fetch --quiet origin "$BRANCH" 2>/dev/null; then - REMOTE_COMMIT="$(git rev-parse "origin/$BRANCH" 2>/dev/null || echo '')" - if [[ -n "$LOCAL_COMMIT" && -n "$REMOTE_COMMIT" ]]; then - BEHIND="$(git rev-list --count "HEAD..origin/$BRANCH" 2>/dev/null || echo 0)" - fi - else - # Reported, not swallowed: "cannot reach the repository" and "already up - # to date" look identical from the panel otherwise, and only one is fine. - # A code, not a sentence: the panel is translated and this script is - # not, so a German string here would surface in the English interface. - FETCH_ERROR='repo_unreachable' - fi + # Reported, not swallowed: "cannot reach the repository" and "already up to + # date" look identical from the panel otherwise, and only one is fine. A + # code, not a sentence: the panel is translated and this script is not, so a + # German string here would surface in the English interface. + FETCH_ERROR='repo_unreachable' fi # ── Is an update asked for, and is it still current? ────────────────────── @@ -277,6 +281,16 @@ if [[ "$REQUEST_KIND" == "check" ]]; then exit 0 fi +# Only ever to a tag, and only to one that is genuinely newer. Checked BEFORE +# anything announces a run: a request with nothing to install is answered, not +# obeyed. The console does not offer the button in that state, so this is a +# stale tab or a forged request — and obeying it would take the site into +# maintenance mode to install what is already installed. +if [[ -z "$TARGET_RELEASE" ]]; then + write_status idle "$FETCH_ERROR" + exit 0 +fi + # The previous run's last step is not this run's first one. Left in place, the # console would show a stale phase for the seconds before update.sh writes its # own — and on a run that dies before writing any, for the whole run. @@ -286,14 +300,7 @@ STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" write_status running "$FETCH_ERROR" set +e -if [[ "$MODE" == "release" && -n "$TARGET_RELEASE" ]]; then - RELEASE="$TARGET_RELEASE" "$ROOT/deploy/update.sh" > "$RUNLOG" 2>&1 -else - # BRANCH passed explicitly: update.sh defaults to main, so an installation - # following anything else would be told about ITS branch and then handed - # main's commits. - BRANCH="$BRANCH" "$ROOT/deploy/update.sh" > "$RUNLOG" 2>&1 -fi +RELEASE="$TARGET_RELEASE" "$ROOT/deploy/update.sh" > "$RUNLOG" 2>&1 RESULT=$? set -e diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 9981f8e..c9fa55f 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -99,6 +99,13 @@ return [ 'update_now' => 'Jetzt aktualisieren', 'update_running' => 'Läuft gerade', 'update_available' => ':n Aktualisierung(en) verfügbar', + // Mit Versionsnummer, sobald der Agent den Tag genannt hat: „Version 1.1.0 + // verfügbar" ist etwas, worüber jemand entscheiden kann. + 'update_available_release' => 'Version :version verfügbar', + // Warum der Knopf grau ist. Aktualisiert wird nur auf eine Version mit Tag, + // nie auf den Stand eines Branches — ein Commit auf main ist keine Freigabe. + 'update_only_releases' => 'Aktualisiert wird nur auf eine freigegebene Version. Was auf main landet, ändert hier nichts, bis daraus eine Version mit Tag wird.', + 'update_nothing_released' => 'Es gibt keine neuere freigegebene Version.', 'update_current' => 'Aktuell', 'update_unknown' => 'Unbekannt', // Kurz gehalten, ohne eigene Zeitangabe: der Live-Countdown, der direkt im diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index e8df274..a6653f8 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -99,6 +99,13 @@ return [ 'update_now' => 'Update now', 'update_running' => 'Running', 'update_available' => ':n update(s) available', + // With the version number once the agent has named the tag: "Version 1.1.0 + // available" is something a person can decide about. + 'update_available_release' => 'Version :version available', + // Why the button is grey. Updates only ever go to a tagged version, never + // to the head of a branch — a commit on main is not a release. + 'update_only_releases' => 'Updates only ever go to a released version. What lands on main changes nothing here until it is tagged as one.', + 'update_nothing_released' => 'There is no newer released version.', 'update_current' => 'Up to date', 'update_unknown' => 'Unknown', // Kept short, with no time of its own: the live countdown shown right diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 97b913e..e397c29 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -37,7 +37,15 @@ @elseif ($update['available']) - {{ __('admin_settings.update_available', ['n' => $update['behind']]) }} + + {{-- The version, when it is known: "Version 1.1.0 + verfügbar" is a thing an operator can decide + about; "1 Aktualisierung verfügbar" is not. --}} + @if ($update['target_release']) + {{ __('admin_settings.update_available_release', ['version' => ltrim($update['target_release'], 'v')]) }} + @else + {{ __('admin_settings.update_available', ['n' => $update['behind']]) }} + @endif @elseif ($update['behind'] === 0) @@ -79,19 +87,21 @@ wants to know whether anything is new must not be routed through a real deployment to find out. - "Jetzt aktualisieren" is offered regardless of $available. - "behind" is a READING, taken up to a minute ago — it is not - the state of the world. Push a commit and for that one - minute the console would otherwise insist everything is - current and refuse to do anything about it, which is - precisely how it was reported. The agent fetches before it - decides, so asking against a stale reading costs one fetch - and finds nothing; being locked out costs the deployment. + "Jetzt aktualisieren" now requires a released version to + install. It used to be offered unconditionally, because + `behind` was a reading taken up to a minute ago and a + console that had not noticed the last commit yet would + refuse to act on it. That reasoning belonged to counting + commits on a branch, where the answer changes several times + an hour. An update is a TAG now: it appears when somebody + releases one, not when somebody pushes — and pressing the + check button answers within a second, so a stale reading is + no longer something to work around. - Both disabled for the two cases where neither would go - anywhere: a run already in flight, and no agent. Also - disabled while a check is already pending, so a second - click cannot collide with the first and be refused. --}} + Disabled for the cases where it would go nowhere: nothing + released above the installed version, a run already in + flight, no agent, or a check already pending (so a second + click cannot collide with the first and be refused). --}}
@if ($update['running']) @@ -106,7 +116,7 @@ {{ $update['checking'] ? __('admin_settings.update_checking') : __('admin_settings.update_check') }} - {{ __('admin_settings.update_now') }} @@ -119,6 +129,11 @@ {{ __('admin_settings.update_no_agent') }} @elseif ($update['last_error']) {{ $update['last_error'] }} + @elseif (! $update['available'] && ! $update['running'] && ! $update['checking']) + {{-- Why the button is grey. Without this the console looks + broken to whoever just pushed to main and expects to see + something: it is not broken, it is waiting for a release. --}} +

{{ __('admin_settings.update_only_releases') }}

@endif {{-- Where it is. "Läuft gerade" alone is what sends an operator to diff --git a/tests/Feature/Admin/UpdateButtonTest.php b/tests/Feature/Admin/UpdateButtonTest.php index 7194a3c..f63a433 100644 --- a/tests/Feature/Admin/UpdateButtonTest.php +++ b/tests/Feature/Admin/UpdateButtonTest.php @@ -527,13 +527,17 @@ it('keeps the panel, the agent and the installer in step about the on-demand tri ->and($agent)->toContain('"request_watch"'); }); -it('offers "Jetzt aktualisieren" even when the last check found nothing', function () { - // Reported as "I cannot run an update, it says everything is current". - // `behind` is a READING taken up to a minute ago, not the state of the - // world: push a commit and the console insisted it was up to date and - // refused to act on it. The agent fetches before deciding, so asking - // against a stale reading costs one fetch — being locked out costs the - // deployment. +/** + * An update is a released version, never the head of a branch. + * + * The console used to offer "Jetzt aktualisieren" unconditionally, because + * `behind` counted commits on main and was a reading a minute old — refusing on + * a stale reading cost the deployment. That whole problem belonged to following + * a branch. Nothing is installed now unless a tag exists whose version is + * higher than the one deployed, and pressing the check button answers within a + * second, so a stale reading is no longer something to design around. + */ +it('refuses to queue an update when nothing newer has been released', function () { writeStatus([ 'checked_at' => now()->toIso8601String(), 'state' => 'idle', @@ -542,12 +546,65 @@ it('offers "Jetzt aktualisieren" even when the last check found nothing', functi Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) - ->assertSee(__('admin_settings.update_now')) - ->call('requestUpdate'); + ->assertSee(__('admin_settings.update_only_releases')) + ->call('requestUpdate') + ->assertDispatched('notify', message: __('admin_settings.update_nothing_released')); + + // Not merely a disabled button: a Livewire action is a public endpoint, and + // a queued run for nothing costs a maintenance window. + expect(app(UpdateChannel::class)->pendingRequest())->toBeNull(); +}); + +it('offers the update, by version, once a release above the installed one exists', function () { + writeStatus([ + 'checked_at' => now()->toIso8601String(), + 'state' => 'idle', + 'behind' => 1, + 'target_release' => 'v1.1.0', + ]); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(AdminSettings::class) + // The number somebody can decide about, not "1 Aktualisierung". + ->assertSee(__('admin_settings.update_available_release', ['version' => '1.1.0'])) + ->assertDontSee(__('admin_settings.update_only_releases')) + ->call('requestUpdate') + ->assertDispatched('notify', message: __('admin_settings.update_requested')); expect(app(UpdateChannel::class)->pendingRequest())->not->toBeNull(); }); +it('does not name a release the agent never reported', function () { + // An older agent writes no target_release. The count is all there is, and + // inventing a version number from it would be worse than the count. + writeStatus([ + 'checked_at' => now()->toIso8601String(), + 'state' => 'idle', + 'behind' => 2, + ]); + + expect(app(UpdateChannel::class)->state()['target_release'])->toBeNull(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(AdminSettings::class) + ->assertSee(__('admin_settings.update_available', ['n' => 2])); +}); + +it('decides what is newer by version and installs only a tag', function () { + // The agent's half of the same rule, which no PHP test can reach: it asks + // one question regardless of what the checkout is attached to, compares by + // version rather than by branch position, and hands update.sh a RELEASE + // rather than a BRANCH. + $agent = File::get(base_path('deploy/update-agent.sh')); + + expect($agent)->toContain('version_gt') + ->and($agent)->toContain('sort -V') + ->and($agent)->toContain('RELEASE="$TARGET_RELEASE"') + // No branch left to fall back onto, which is what made a commit on main + // installable in the first place. + ->and($agent)->not->toContain('BRANCH="$BRANCH" "$ROOT/deploy/update.sh"'); +}); + it('offers both actions together whenever the agent is reachable and idle', function () { // Two separate buttons now, not one whose label changed with the // reading: looking and applying are different intentions.