Skip to content

Commit d4f58d6

Browse files
committed
fix: detect source checkout updates
Signed-off-by: namabeeru <github.body594@passmail.com>
1 parent 5558698 commit d4f58d6

4 files changed

Lines changed: 390 additions & 45 deletions

File tree

app/updater.py

Lines changed: 135 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -38,50 +38,129 @@ def is_newer(remote: str, local: str) -> bool:
3838
# ---------------------------------------------------------------------------
3939

4040
GITHUB_REPO = "CraftOS-dev/CraftBot"
41-
GITHUB_LATEST_RELEASE_URL = f"https://api.github.com/repos/{GITHUB_REPO}/tags"
41+
GITHUB_TAGS_URL = f"https://api.github.com/repos/{GITHUB_REPO}/tags"
42+
GITHUB_LATEST_RELEASE_URL = GITHUB_TAGS_URL
43+
UPDATE_BRANCH = "main"
44+
GIT_PROBE_TIMEOUT = 15
4245

4346

4447
async def check_for_update() -> Tuple[bool, str, str]:
4548
"""Check whether a newer version is available on the remote repo.
4649
47-
Fetches the latest git tag from GitHub (e.g. ``v1.2.2``) and compares
48-
it against the local version stored in settings.json.
50+
Source checkouts can be ahead or behind the update branch while still
51+
carrying the same tagged app version. Keep the release tag as the primary
52+
version signal, then use a git comparison to catch source-checkout updates
53+
when the release tag is unchanged.
4954
5055
Returns:
5156
(update_available, current_version, latest_version)
5257
"""
5358
from app.config import get_app_version
5459

60+
current = get_app_version()
61+
project_root = Path(__file__).resolve().parent.parent
62+
63+
release_update = await _check_release_update(current)
64+
if release_update[0]:
65+
return release_update
66+
67+
source_update = await _check_source_update(project_root, current)
68+
if source_update is not None:
69+
return source_update
70+
71+
return release_update
72+
73+
74+
async def _check_source_update(
75+
project_root: Path,
76+
current: str,
77+
branch: str = UPDATE_BRANCH,
78+
) -> Optional[Tuple[bool, str, str]]:
79+
"""Check whether this git checkout is behind the configured update branch.
80+
81+
Returns ``None`` when the probe is inconclusive so callers can fall back to
82+
the release-tag check instead of blocking update checks on git-specific
83+
failures.
84+
"""
85+
if getattr(sys, "frozen", False):
86+
return None
87+
88+
try:
89+
inside, _ = await _run_git(
90+
["git", "rev-parse", "--is-inside-work-tree"], str(project_root)
91+
)
92+
if _decode_git_stdout(inside) != "true":
93+
return None
94+
95+
await _run_git(
96+
["git", "fetch", "origin", f"{branch}:refs/remotes/origin/{branch}"],
97+
str(project_root),
98+
)
99+
local_stdout, _ = await _run_git(
100+
["git", "rev-parse", "HEAD"], str(project_root)
101+
)
102+
remote_stdout, _ = await _run_git(
103+
["git", "rev-parse", f"origin/{branch}"], str(project_root)
104+
)
105+
106+
local_revision = _decode_git_stdout(local_stdout)
107+
remote_revision = _decode_git_stdout(remote_stdout)
108+
if not local_revision or not remote_revision:
109+
return None
110+
if local_revision == remote_revision:
111+
return False, current, current
112+
113+
count_stdout, _ = await _run_git(
114+
[
115+
"git",
116+
"rev-list",
117+
"--left-right",
118+
"--count",
119+
f"HEAD...origin/{branch}",
120+
],
121+
str(project_root),
122+
)
123+
_ahead_text, behind_text = _decode_git_stdout(count_stdout).split()
124+
behind = int(behind_text)
125+
if behind > 0:
126+
latest = f"{current}+{branch}.{remote_revision[:7]}"
127+
return True, current, latest
128+
129+
return False, current, current
130+
except Exception:
131+
return None
132+
133+
134+
async def _check_release_update(current: str) -> Tuple[bool, str, str]:
135+
"""Check GitHub release tags against the local app version."""
55136
import aiohttp
56137

57-
current = get_app_version()
58138
try:
59139
headers = {"Accept": "application/vnd.github.v3+json"}
60140
async with aiohttp.ClientSession() as session:
61141
async with session.get(
62-
GITHUB_LATEST_RELEASE_URL,
142+
GITHUB_TAGS_URL,
63143
headers=headers,
64144
timeout=aiohttp.ClientTimeout(total=15),
65145
) as resp:
66146
tags = await resp.json(content_type=None)
67-
68-
if not tags or not isinstance(tags, list):
69-
return False, current, current
70-
71-
# Find the highest semver tag (tags are not guaranteed sorted)
72-
latest = "0.0.0"
73-
for tag in tags:
74-
name = tag.get("name", "")
75-
try:
76-
if parse_version(name) > parse_version(latest):
77-
latest = name.strip().lstrip("vV")
78-
except (ValueError, AttributeError):
79-
continue
80-
81147
except Exception:
82-
# Network error — treat as "no update available"
148+
# Network error — treat as "no update available".
149+
return False, current, current
150+
151+
if not tags or not isinstance(tags, list):
83152
return False, current, current
84153

154+
# Find the highest semver tag. GitHub tags are not guaranteed sorted.
155+
latest = "0.0.0"
156+
for tag in tags:
157+
name = tag.get("name", "")
158+
try:
159+
if parse_version(name) > parse_version(latest):
160+
latest = name.strip().lstrip("vV")
161+
except (ValueError, AttributeError):
162+
continue
163+
85164
return is_newer(latest, current), current, latest
86165

87166

@@ -95,14 +174,12 @@ async def check_for_update() -> Tuple[bool, str, str]:
95174
async def perform_update(
96175
progress_callback: Optional[Callable[[str], Awaitable[None]]] = None,
97176
) -> None:
98-
"""Launch the external updater script in a new window, then shut down.
99-
100-
The updater script (scripts/updater.bat on Windows) runs in its own
101-
visible terminal and handles: waiting for us to exit, git pull, install,
102-
and relaunch. This keeps the update logic out of the running Python
103-
process — no in-process git mutation, no exit-code signalling, no
104-
console-visibility hacks. If the updater fails, its window stays open
105-
showing the error.
177+
"""Launch the external updater script, then shut down.
178+
179+
The script waits for CraftBot to exit, pulls the update branch, installs
180+
dependencies, and relaunches CraftBot. Running that work outside this
181+
process avoids in-process git mutation and exit-code signalling. Failures
182+
are written to updater.log.
106183
"""
107184

108185
async def emit(msg: str) -> None:
@@ -111,12 +188,8 @@ async def emit(msg: str) -> None:
111188

112189
project_root = Path(__file__).resolve().parent.parent
113190

114-
target_branch = "main"
115-
116-
if sys.platform == "win32":
117-
updater_script = project_root / "scripts" / "updater.bat"
118-
else:
119-
updater_script = project_root / "scripts" / "updater.sh"
191+
target_branch = UPDATE_BRANCH
192+
updater_script = _updater_script_path(project_root)
120193

121194
if not updater_script.exists():
122195
raise RuntimeError(f"Updater script not found: {updater_script}")
@@ -131,14 +204,14 @@ async def emit(msg: str) -> None:
131204
CREATE_NO_WINDOW = 0x08000000
132205
DETACHED_PROCESS = 0x00000008
133206
subprocess.Popen(
134-
[str(updater_script), target_branch],
207+
[str(updater_script), target_branch, sys.executable],
135208
cwd=str(project_root),
136209
creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW,
137210
close_fds=True,
138211
)
139212
else:
140213
subprocess.Popen(
141-
["sh", str(updater_script), target_branch],
214+
["sh", str(updater_script), target_branch, sys.executable],
142215
cwd=str(project_root),
143216
start_new_session=True,
144217
)
@@ -155,15 +228,39 @@ async def emit(msg: str) -> None:
155228
# ---------------------------------------------------------------------------
156229

157230

158-
async def _run_git(cmd: list, cwd: str) -> Tuple[bytes, bytes]:
231+
def _decode_git_stdout(stdout: bytes) -> str:
232+
return stdout.decode("utf-8", errors="replace").strip()
233+
234+
235+
def _updater_script_path(project_root: Path, platform: str = sys.platform) -> Path:
236+
if platform == "win32":
237+
return project_root / "scripts" / "updater.bat"
238+
return project_root / "scripts" / "updater.sh"
239+
240+
241+
async def _run_git(
242+
cmd: list, cwd: str, timeout: int = GIT_PROBE_TIMEOUT
243+
) -> Tuple[bytes, bytes]:
159244
"""Run a git command asynchronously; raise on non-zero exit."""
245+
env = os.environ.copy()
246+
env.setdefault("GIT_TERMINAL_PROMPT", "0")
247+
160248
proc = await asyncio.create_subprocess_exec(
161249
*cmd,
162250
cwd=cwd,
251+
env=env,
163252
stdout=asyncio.subprocess.PIPE,
164253
stderr=asyncio.subprocess.PIPE,
165254
)
166-
stdout, stderr = await proc.communicate()
255+
try:
256+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
257+
except asyncio.TimeoutError as exc:
258+
proc.kill()
259+
stdout, stderr = await proc.communicate()
260+
raise RuntimeError(
261+
f"{' '.join(cmd)} timed out after {timeout} seconds"
262+
) from exc
263+
167264
if proc.returncode != 0:
168265
err = (
169266
stderr.decode("utf-8", errors="replace").strip()

scripts/updater.bat

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,48 @@
22
setlocal
33

44
rem Argument 1 is the git branch to pull (default: main).
5-
set BRANCH=%1
5+
set "BRANCH=%~1"
66
if "%BRANCH%"=="" set BRANCH=main
77

8+
rem Argument 2 is the Python executable that launched CraftBot.
9+
set "PYTHON_BIN=%~2"
10+
if "%PYTHON_BIN%"=="" set PYTHON_BIN=python
11+
812
rem Project root is the parent of scripts/
913
cd /d "%~dp0.."
1014

1115
rem Log everything to updater.log so failures are debuggable (we run headlessly).
12-
set LOG=%~dp0..\updater.log
16+
set "LOG=%~dp0..\updater.log"
1317
echo. >> "%LOG%"
1418
echo ============================================ >> "%LOG%"
1519
echo %DATE% %TIME% - Updater start (branch=%BRANCH%) >> "%LOG%"
1620
echo CWD=%CD% >> "%LOG%"
21+
echo Python=%PYTHON_BIN% >> "%LOG%"
1722

1823
rem Wait for current CraftBot to fully terminate.
1924
timeout /t 3 /nobreak > nul
2025

2126
echo --- git fetch --- >> "%LOG%"
22-
git fetch origin %BRANCH% >> "%LOG%" 2>&1
27+
git fetch origin "%BRANCH%" >> "%LOG%" 2>&1
2328
if errorlevel 1 goto :fail
2429

2530
echo --- git checkout --- >> "%LOG%"
26-
git checkout %BRANCH% >> "%LOG%" 2>&1
31+
git checkout "%BRANCH%" >> "%LOG%" 2>&1
2732
if errorlevel 1 goto :fail
2833

2934
echo --- git pull --- >> "%LOG%"
30-
git pull origin %BRANCH% >> "%LOG%" 2>&1
35+
git pull --ff-only origin "%BRANCH%" >> "%LOG%" 2>&1
3136
if errorlevel 1 goto :fail
3237

3338
if exist install.py (
3439
echo --- install.py --- >> "%LOG%"
35-
python install.py >> "%LOG%" 2>&1
40+
"%PYTHON_BIN%" install.py >> "%LOG%" 2>&1
3641
if errorlevel 1 goto :fail
3742
)
3843

3944
echo --- relaunching CraftBot --- >> "%LOG%"
4045
rem Launch the new CraftBot. This bat process exits and the new run.py takes over.
41-
start "CraftBot" python run.py --conda
46+
start "CraftBot" "%PYTHON_BIN%" run.py --conda
4247
echo %DATE% %TIME% - Updater done, relaunched CraftBot >> "%LOG%"
4348
exit /b 0
4449

scripts/updater.sh

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#!/bin/sh
2+
set -u
3+
4+
# Argument 1 is the git branch to pull (default: main).
5+
BRANCH="${1:-main}"
6+
7+
# Argument 2 is the Python executable that launched CraftBot. Reusing it keeps
8+
# updates on the same interpreter and avoids python/python3 mismatches.
9+
PYTHON_BIN="${2:-${PYTHON:-python3}}"
10+
11+
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
12+
ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
13+
LOG="$ROOT_DIR/updater.log"
14+
15+
log() {
16+
printf '%s\n' "$*" >> "$LOG"
17+
}
18+
19+
fail() {
20+
log "$(date) - UPDATE FAILED: $*"
21+
exit 1
22+
}
23+
24+
{
25+
printf '\n'
26+
printf '============================================\n'
27+
printf '%s - Updater start (branch=%s)\n' "$(date)" "$BRANCH"
28+
printf 'CWD=%s\n' "$ROOT_DIR"
29+
printf 'Python=%s\n' "$PYTHON_BIN"
30+
} >> "$LOG"
31+
32+
cd "$ROOT_DIR" || fail "could not cd to $ROOT_DIR"
33+
34+
# Wait for current CraftBot to fully terminate.
35+
sleep 3
36+
37+
log "--- git fetch ---"
38+
git fetch origin "$BRANCH" >> "$LOG" 2>&1 || fail "git fetch failed"
39+
40+
log "--- git checkout ---"
41+
git checkout "$BRANCH" >> "$LOG" 2>&1 || fail "git checkout failed"
42+
43+
log "--- git pull ---"
44+
git pull --ff-only origin "$BRANCH" >> "$LOG" 2>&1 || fail "git pull failed"
45+
46+
if [ -f install.py ]; then
47+
log "--- install.py ---"
48+
"$PYTHON_BIN" install.py >> "$LOG" 2>&1 || fail "install.py failed"
49+
fi
50+
51+
log "--- relaunching CraftBot ---"
52+
nohup "$PYTHON_BIN" run.py --conda >> "$LOG" 2>&1 &
53+
log "$(date) - Updater done, relaunched CraftBot"
54+
exit 0

0 commit comments

Comments
 (0)