Skip to content

Commit a4d1384

Browse files
committed
fix(update): revalidate stale startup cache
1 parent 723fe11 commit a4d1384

3 files changed

Lines changed: 144 additions & 35 deletions

File tree

.github/workflows/promote-release.yml

Lines changed: 74 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ name: Promote release
33
# Platform build workflows (windows-installer, linux-installer,
44
# release-pythinker-cli) create the GitHub Release as a PRERELEASE so it stays
55
# out of the date-based /releases/latest endpoint (which ignores make_latest)
6-
# until every asset has uploaded. This workflow waits for all expected assets,
7-
# then clears `prerelease` and marks the release latest — the single point
8-
# where a version becomes resolvable by the install scripts and in-app updater.
6+
# until every install channel is ready. This workflow waits for exact release
7+
# assets, PyPI, and the Homebrew formula, then clears `prerelease` and marks the
8+
# release latest — the single point where a version becomes resolvable by the
9+
# install scripts and in-app updater.
910
#
1011
# It runs on the tag push (not `release: published`, which a GITHUB_TOKEN-created
1112
# release never fires) so promotion always happens. workflow_dispatch allows a
@@ -55,24 +56,44 @@ jobs:
5556
fi
5657
echo "tag=$tag" >> "$GITHUB_OUTPUT"
5758
58-
- name: Wait for all release assets
59+
- name: Wait for install-channel readiness
5960
env:
6061
GH_TOKEN: ${{ github.token }}
6162
TAG: ${{ steps.tag.outputs.tag }}
6263
REPO: ${{ github.repository }}
6364
run: |
6465
set -euo pipefail
65-
required=(
66-
"PythinkerSetup-"
67-
"_amd64.deb"
68-
"_arm64.deb"
69-
".x86_64.rpm"
70-
".aarch64.rpm"
71-
"x86_64-unknown-linux-gnu.tar.gz"
72-
"aarch64-unknown-linux-gnu.tar.gz"
73-
"aarch64-apple-darwin.tar.gz"
74-
"x86_64-apple-darwin.tar.gz"
66+
version="${TAG#v}"
67+
required_assets=(
68+
"PythinkerSetup-${version}.exe"
69+
"PythinkerSetup-${version}.exe.sha256"
70+
"pythinker-code_${version}_amd64.deb"
71+
"pythinker-code_${version}_amd64.deb.sha256"
72+
"pythinker-code_${version}_arm64.deb"
73+
"pythinker-code_${version}_arm64.deb.sha256"
74+
"pythinker-code-${version}.x86_64.rpm"
75+
"pythinker-code-${version}.x86_64.rpm.sha256"
76+
"pythinker-code-${version}.aarch64.rpm"
77+
"pythinker-code-${version}.aarch64.rpm.sha256"
78+
"pythinker-${version}-x86_64-unknown-linux-gnu.tar.gz"
79+
"pythinker-${version}-x86_64-unknown-linux-gnu.tar.gz.sha256"
80+
"pythinker-${version}-aarch64-unknown-linux-gnu.tar.gz"
81+
"pythinker-${version}-aarch64-unknown-linux-gnu.tar.gz.sha256"
82+
"pythinker-${version}-aarch64-apple-darwin.tar.gz"
83+
"pythinker-${version}-aarch64-apple-darwin.tar.gz.sha256"
84+
"pythinker-${version}-x86_64-apple-darwin.tar.gz"
85+
"pythinker-${version}-x86_64-apple-darwin.tar.gz.sha256"
86+
"pythinker-${version}-x86_64-unknown-linux-gnu-onedir.tar.gz"
87+
"pythinker-${version}-x86_64-unknown-linux-gnu-onedir.tar.gz.sha256"
88+
"pythinker-${version}-aarch64-unknown-linux-gnu-onedir.tar.gz"
89+
"pythinker-${version}-aarch64-unknown-linux-gnu-onedir.tar.gz.sha256"
90+
"pythinker-${version}-aarch64-apple-darwin-onedir.tar.gz"
91+
"pythinker-${version}-aarch64-apple-darwin-onedir.tar.gz.sha256"
92+
"pythinker-${version}-x86_64-apple-darwin-onedir.tar.gz"
93+
"pythinker-${version}-x86_64-apple-darwin-onedir.tar.gz.sha256"
7594
)
95+
pypi_url="https://pypi.org/pypi/pythinker-code/${version}/json"
96+
homebrew_formula_url="https://raw.githubusercontent.com/TechMatrix-labs/homebrew-pythinker/main/Formula/pythinker-code.rb"
7697
# The budget must comfortably exceed the slowest platform build, since
7798
# this job runs on the tag push in parallel with them. The long pole is
7899
# linux-installer's emulated arm64 .deb/.rpm step: on the 0.26.0 release
@@ -83,26 +104,51 @@ jobs:
83104
max_attempts=80
84105
poll_interval=30
85106
budget_min=$(( max_attempts * poll_interval / 60 ))
86-
echo "Polling for all release assets on $TAG (up to ${budget_min}m)..."
87-
all_present=false
107+
echo "Polling install-channel readiness for $TAG (up to ${budget_min}m)..."
108+
all_ready=false
88109
for i in $(seq 1 "$max_attempts"); do
89-
assets=$(gh api "repos/$REPO/releases/tags/$TAG" --jq '[.assets[].name] | join(" ")' 2>/dev/null || echo "")
90-
all_present=true
91-
for p in "${required[@]}"; do
92-
if [[ "$assets" != *"$p"* ]]; then
93-
all_present=false
94-
break
110+
assets_json=$(gh api "repos/$REPO/releases/tags/$TAG" --jq '[.assets[].name]' 2>/dev/null || printf '[]')
111+
missing_assets=()
112+
for asset in "${required_assets[@]}"; do
113+
if ! jq -e --arg name "$asset" 'index($name)' <<<"$assets_json" >/dev/null; then
114+
missing_assets+=("$asset")
95115
fi
96116
done
97-
if [[ "$all_present" == "true" ]]; then
98-
echo "All assets present (attempt $i)"
117+
118+
pypi_ready=false
119+
if curl -fsSL --retry 2 --retry-delay 2 -o /dev/null "$pypi_url"; then
120+
pypi_ready=true
121+
fi
122+
123+
homebrew_ready=false
124+
formula_text=$(curl -fsSL --retry 2 --retry-delay 2 "$homebrew_formula_url" 2>/dev/null || true)
125+
if grep -qF "version \"${version}\"" <<<"$formula_text"; then
126+
homebrew_ready=true
127+
fi
128+
129+
if [[ "${#missing_assets[@]}" -eq 0 && "$pypi_ready" == "true" && "$homebrew_ready" == "true" ]]; then
130+
all_ready=true
131+
echo "All install channels ready (attempt $i)"
99132
break
100133
fi
101-
echo "Attempt $i/$max_attempts: assets not ready, retrying in ${poll_interval}s..."
102-
sleep "$poll_interval"
134+
135+
echo "Attempt $i/$max_attempts: install channels not ready."
136+
if [[ "${#missing_assets[@]}" -gt 0 ]]; then
137+
printf 'Missing release assets: %s\n' "${missing_assets[*]}"
138+
fi
139+
if [[ "$pypi_ready" != "true" ]]; then
140+
echo "PyPI is not serving ${version} yet: $pypi_url"
141+
fi
142+
if [[ "$homebrew_ready" != "true" ]]; then
143+
echo "Homebrew tap formula is not at ${version} yet: $homebrew_formula_url"
144+
fi
145+
if [[ "$i" -lt "$max_attempts" ]]; then
146+
echo "Retrying in ${poll_interval}s..."
147+
sleep "$poll_interval"
148+
fi
103149
done
104-
if [[ "$all_present" != "true" ]]; then
105-
echo "::error::Release assets not fully uploaded after ${budget_min} minutes"
150+
if [[ "$all_ready" != "true" ]]; then
151+
echo "::error::Install channels were not fully ready after ${budget_min} minutes"
106152
exit 1
107153
fi
108154

src/pythinker_code/ui/shell/update.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
LAST_UPDATE_CHECK_FILE = get_share_dir() / "last_update_check.txt"
5353
DISMISSED_VERSION_FILE = get_share_dir() / "dismissed_update_version.txt"
5454
AUTO_UPDATE_CHECK_INTERVAL_SECONDS = 24 * 60 * 60
55+
PROMPT_UPDATE_REFRESH_TIMEOUT_SECONDS = 2.0
5556

5657
_UPDATE_LOCK = asyncio.Lock()
5758
_skipped_version_this_session: str | None = None
@@ -404,17 +405,32 @@ async def _refresh_update_cache(*, force: bool) -> UpdateResult | None:
404405
return result
405406

406407

408+
async def _refresh_update_cache_for_prompt(*, force: bool) -> UpdateResult | None:
409+
"""Refresh the startup-prompt version cache without hanging shell startup."""
410+
try:
411+
return await asyncio.wait_for(
412+
_refresh_update_cache(force=force),
413+
timeout=PROMPT_UPDATE_REFRESH_TIMEOUT_SECONDS,
414+
)
415+
except TimeoutError:
416+
logger.warning("Update prompt refresh timed out; using cached latest version")
417+
return None
418+
419+
407420
async def _resolve_latest_version_for_prompt(*, force_refresh: bool = False) -> str | None:
408-
"""Return the latest known native release, refreshing when due or uncached.
421+
"""Return the latest known native release for the pre-start prompt.
409422
410-
Used by the blocking pre-start prompt and by the explicit ``/update`` flow.
411-
A missing cache forces one network check so fresh installs can be prompted
412-
before the agent starts instead of seeing only a later background notice.
413-
``/update`` passes ``force_refresh=True`` so explicit user checks hit the
414-
release API even when the startup throttle is not due.
423+
The background notifier uses a 24h throttle, but the blocking startup prompt
424+
must not trust a cached "already current" answer forever: a release can land
425+
minutes after the last successful check. Revalidate missing/stale caches with
426+
a short timeout and GitHub's cached ETag; keep the throttle only when the
427+
cached version is already newer than the running version.
415428
"""
429+
from pythinker_code.constant import VERSION as current_version
430+
416431
cached = _read_latest_version_cache()
417-
refresh_result = await _refresh_update_cache(force=force_refresh or not cached)
432+
cached_is_stale = cached is None or semver_tuple(cached) <= semver_tuple(current_version)
433+
refresh_result = await _refresh_update_cache_for_prompt(force=force_refresh or cached_is_stale)
418434
if refresh_result is not None:
419435
return _read_latest_version_cache() or cached
420436
return cached

tests/ui_and_conv/test_shell_update.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import asyncio
34
from pathlib import Path
45
from types import SimpleNamespace
56

@@ -319,6 +320,52 @@ async def fail_do_update(*, print: bool, check_only: bool) -> update.UpdateResul
319320
assert await update._resolve_latest_version_for_prompt() == "3.1.0"
320321

321322

323+
@pytest.mark.asyncio
324+
async def test_resolve_latest_version_revalidates_stale_cache_when_not_due(monkeypatch, tmp_path):
325+
latest_file = tmp_path / "latest.txt"
326+
latest_file.write_text("0.0.0", encoding="utf-8")
327+
last_check_file = tmp_path / "last_update_check.txt"
328+
calls: list[tuple[bool, bool]] = []
329+
330+
async def fake_do_update(*, print: bool, check_only: bool) -> update.UpdateResult:
331+
calls.append((print, check_only))
332+
latest_file.write_text("999.0.0", encoding="utf-8")
333+
return update.UpdateResult.UPDATE_AVAILABLE
334+
335+
def fail_should_auto_check() -> bool:
336+
raise AssertionError("stale prompt cache must bypass the 24h throttle")
337+
338+
monkeypatch.setattr(update, "LATEST_VERSION_FILE", latest_file)
339+
monkeypatch.setattr(update, "LAST_UPDATE_CHECK_FILE", last_check_file)
340+
monkeypatch.setattr(update, "_should_auto_check_for_updates", fail_should_auto_check)
341+
monkeypatch.setattr(update, "do_update", fake_do_update)
342+
343+
assert await update._resolve_latest_version_for_prompt() == "999.0.0"
344+
assert calls == [(False, True)]
345+
assert last_check_file.exists()
346+
347+
348+
@pytest.mark.asyncio
349+
async def test_resolve_latest_version_keeps_cache_when_prompt_refresh_times_out(
350+
monkeypatch, tmp_path
351+
):
352+
latest_file = tmp_path / "latest.txt"
353+
latest_file.write_text("0.0.0", encoding="utf-8")
354+
calls: list[bool] = []
355+
356+
async def slow_refresh(*, force: bool) -> update.UpdateResult:
357+
calls.append(force)
358+
await asyncio.sleep(60)
359+
return update.UpdateResult.UPDATE_AVAILABLE
360+
361+
monkeypatch.setattr(update, "LATEST_VERSION_FILE", latest_file)
362+
monkeypatch.setattr(update, "PROMPT_UPDATE_REFRESH_TIMEOUT_SECONDS", 0.01)
363+
monkeypatch.setattr(update, "_refresh_update_cache", slow_refresh)
364+
365+
assert await update._resolve_latest_version_for_prompt() == "0.0.0"
366+
assert calls == [True]
367+
368+
322369
@pytest.mark.asyncio
323370
async def test_refresh_cache_does_not_throttle_on_failure(monkeypatch, tmp_path):
324371
"""A failed check must not mark the throttle file.

0 commit comments

Comments
 (0)