Update only to a released version, never to whatever landed on main
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
parent
b64a9f9238
commit
21b7aaca42
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
# 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 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" && "$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"
|
||||
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
|
||||
# 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
|
||||
# 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
|
||||
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.
|
||||
# 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
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue