Update only to a released version, never to whatever landed on main
tests / pest (push) Failing after 7m21s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

Asked for directly: a commit on main must change nothing on a server, and only
a tag whose version is higher than the installed one may be installed.

The agent asked two different questions before this. A pinned server compared
tags; everything else counted commits on its branch — so a console following
main offered an update for every push, verified or not, and "Jetzt
aktualisieren" meant "take whatever is on the branch right now". Landing on main
and being released are different events, and only the second is a decision
somebody made.

It is one question now, regardless of what the checkout is attached to: is there
a tag whose version is higher than the deployed one? Compared with sort -V
against the version in the deployment manifest — not the VERSION file, which an
update moves before it migrates, so a run that failed in between would leave the
checkout claiming a version it never finished installing. A run always targets
that tag; the branch path is gone. A request that arrives with nothing to
install is answered rather than obeyed, before anything announces a run.

The console follows: the button is disabled with nothing released, says so in a
line rather than leaving a grey button to be guessed at, names the version it
would install rather than a count, and refuses the action server-side as well —
a Livewire action is a public endpoint, and one place a rule may not live is
only in the disabled attribute of a button.

VERSION becomes 1.1.0, to be tagged as the first release this mechanism can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/granted-plans v1.1.0
nexxo 2026-07-28 22:32:44 +02:00
parent b64a9f9238
commit 21b7aaca42
9 changed files with 190 additions and 72 deletions

View File

@ -1 +1 @@
1.0.0
1.1.0

View File

@ -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

View File

@ -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,

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -37,7 +37,15 @@
</span>
@elseif ($update['available'])
<span class="inline-flex items-center gap-1.5 rounded-pill border border-accent-border bg-accent-subtle px-2.5 py-0.5 text-xs font-medium text-accent-text">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_available', ['n' => $update['behind']]) }}
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{-- 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
</span>
@elseif ($update['behind'] === 0)
<span class="inline-flex items-center gap-1.5 rounded-pill border border-success-border bg-success-bg px-2.5 py-0.5 text-xs font-medium text-success">
@ -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). --}}
<div class="flex shrink-0 items-center gap-2">
@if ($update['running'])
<x-ui.button variant="secondary" :disabled="true">
@ -106,7 +116,7 @@
<x-ui.icon name="refresh" class="size-4 {{ $update['checking'] ? 'animate-spin' : '' }}" />
{{ $update['checking'] ? __('admin_settings.update_checking') : __('admin_settings.update_check') }}
</x-ui.button>
<x-ui.button variant="primary" :disabled="$update['checking']"
<x-ui.button variant="primary" :disabled="$update['checking'] || ! $update['available']"
wire:click="requestUpdate" wire:loading.attr="disabled" wire:target="requestUpdate">
<x-ui.icon name="download" class="size-4" />
{{ __('admin_settings.update_now') }}
@ -119,6 +129,11 @@
<x-ui.alert variant="warning" class="mt-4">{{ __('admin_settings.update_no_agent') }}</x-ui.alert>
@elseif ($update['last_error'])
<x-ui.alert variant="danger" class="mt-4">{{ $update['last_error'] }}</x-ui.alert>
@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. --}}
<p class="mt-3 text-xs text-muted">{{ __('admin_settings.update_only_releases') }}</p>
@endif
{{-- Where it is. "Läuft gerade" alone is what sends an operator to

View File

@ -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.