9.6 KiB
Version Update Check & Download for OriginEvoScientist
Date: 2026-08-03
Status: Implemented (2026-08-06)
Inspired by: sub2api UpdateService + VersionBadge (see ~/Projects/EvoSci/sub2api/sub2api)
Implementation deviations from the original draft:
- "Latest" is the max semver across
/releases?limit=20and/tags?limit=20(notreleases/latest): Gitea ordersreleases/latestby tag creation date, so v0.2.2 (tagged 2026-07-11) ranked below v0.1.19 (tagged 2026-07-13). release.shbuilds in a temporarygit worktreeat the tag so a dirty working tree can't leak into artifacts, and supports version == pyproject (reuses the existing tag) for baseline cuts.- Backend routes live in
langgraph_dev/http.pybehind the model-registryBffAuthenticatorwith a newsystem:readscope (added to both WebUI roles insrc/lib/server/delegation.ts). - The Gitea checker extends the existing
EvoScientist/update_check.py(PyPI startup check untouched). - No component render test for VersionBadge — the repo has no jsdom/testing -library harness (node-env vitest only); verified in-browser via Playwright.
Context
Port sub2api's "version badge + update prompt" to OriginEvoScientist, using the self-hosted Gitea instance git.foksai.com (verified: Gitea 1.26.4, anonymous read access, GitHub-compatible release schema) as the release source.
Verified facts (2026-08-03):
GET https://git.foksai.com/api/v1/repos/ouyangbo/EvoScientist/releases/latest→ 200,tag_name=v0.1.19, schema matches GitHub (tag_name/name/body/html_url/ draft/prerelease/tarball_url/assets).- Latest git tag is
v0.1.20(tag without a release exists). ouyangbo/EvoScientist-WebUIhas no releases and no tags (404 fromreleases/latest— must be treated as "never published", not an error).- Local
pyproject.tomlis already0.2.2→ release baseline lags behind the code; Part 1 must cut a fresh release first orhas_updatecan never fire. - Asset/attachment URLs returned by the API use the
git.foksai.com:8443host (instance's HTTPS port); the download allowlist must compare host, ignoring port or allowing:8443explicitly.
Non-goal: automatic in-place upgrade. EvoScientist runs from source (Python venv + Next.js build), so sub2api's atomic-binary-swap does not translate. We download + verify + present the install command; the operator applies it.
Decisions
| Question | Decision |
|---|---|
| What counts as "latest"? | releases/latest; if 404, fall back to max semver tag via /tags?limit=20 |
| Which repo/component? | ouyangbo/EvoScientist (backend) only; WebUI deferred until it starts tagging |
| Current version source | importlib.metadata.version("EvoScientist") (pyproject is the single source) |
| Release creation | Local scripts/release.sh + Gitea API token; Gitea Actions later (instance capability unverified) |
| Auto-apply | No — download to staging dir, show install command |
Env config (backend):
EVOSCIENTIST_UPDATE_BASE_URL— defaulthttps://git.foksai.comEVOSCIENTIST_UPDATE_REPO— defaultouyangbo/EvoScientistEVOSCIENTIST_UPDATE_CHECK_DISABLED— set to1to disable outbound checksGITEA_TOKEN— only needed byrelease.sh(writes), not by the checker
Part 1 — Publishing new versions (EvoScientist repo)
Task 1.1: scripts/release.sh
Inputs: scripts/release.sh 0.2.3 "Release notes text" (version arg required;
script refuses if it doesn't differ from pyproject).
Steps:
sedbumpversion = "X.Y.Z"inpyproject.toml;uv lock(if lockfile tracks version); commitchore(release): vX.Y.Z;git tag vX.Y.Z;git push origin main --tags.uv build→dist/EvoScientist-X.Y.Z.tar.gz,dist/evoscientist-X.Y.Z-*.whl.shasum -a 256 dist/* > dist/checksums.txt.- Create release via API:
POST $BASE/api/v1/repos/ouyangbo/EvoScientist/releaseswith{tag_name, name: "Release vX.Y.Z", body}; headerAuthorization: token $GITEA_TOKEN. - Upload each asset:
POST /repos/.../releases/{id}/assets?name=<fname>(binary body). - Verify:
GET releases/latestreturns the new tag.
Task 1.2: cut baseline release v0.2.2 — run the script once so the checker has a meaningful comparison target.
Task 1.3 (deferred): Gitea Actions — probe
GET /api/v1/repos/ouyangbo/EvoScientist/actions/tasks (or instance admin)
to see if Actions is enabled; if yes, port .github/workflows/build.yml into
.gitea/workflows/release.yml triggered on push: tags: ["v*"] doing steps
2–5. Not required for v1.
Part 2 — Update check
Task 2.1: backend module EvoScientist/update_check.py (new file)
class UpdateInfo(TypedDict):
current_version: str
latest_version: str
has_update: bool
release_url: str | None
release_notes: str | None
published_at: str | None
cached: bool
warning: str | None
def get_update_info(*, force: bool = False) -> UpdateInfo
- Current version:
importlib.metadata.version("EvoScientist"), fallback"0.0.0-dev"onPackageNotFoundError. - Latest:
GET {base}/api/v1/repos/{repo}/releases/latest(10s timeout).- 404 →
GET {base}/api/v1/repos/{repo}/tags?limit=20, take max semver. - Other network/HTTP error → return last cached value with
warningset; if no cache,latest = current,has_update = False,warningset.
- 404 →
- Version compare: strip leading
v, split., int-compare 3 segments (mirror sub2apicompareVersions,update_service.go:641); non-numeric segments compare as 0. - Cache: module-level
{"info": ..., "fetched_at": ...}, TTL 1200s;forcebypasses. Mirror sub2api TTL (update_service.go:32). - Disabled switch:
EVOSCIENTIST_UPDATE_CHECK_DISABLED=1→ immediately return current==latest, no network. - HTTP client:
httpx(already a dependency).
Task 2.2: backend route in EvoScientist/langgraph_dev/http.py (alongside
the existing /internal/workspace-scopes/* routes, ~line 878):
GET /internal/system/version?force=true→ JSON fromget_update_info.
Task 2.3: WebUI BFF src/app/api/system/version/route.ts (new)
GETproxies to{backend}/internal/system/version, forwardsforcequery param, returns backend JSON; on backend failure → 502 with the existing error-shape used by other BFF routes (mirrorsrc/app/api/skills/route.ts).
Task 2.4: WebUI component src/app/components/VersionBadge.tsx (new)
Modeled on sub2api VersionBadge.vue and our ErrorLogBell.tsx:
- Button showing
v{current_version}; whenhas_update: amber background + ping-dot indicator. - Dropdown: current version, latest version, "View release notes" link
(
release_url, opens new tab), refresh button (force=true), and the download section from Part 3. - SWR over
/api/system/version, fetch on mount + on dropdown open; no aggressive polling (server caches 20 min anyway). - Mount in
src/app/page.tsxheader next to<ErrorLogBell />. - All strings English.
Part 3 — Update download
Task 3.1: backend download — extend update_check.py:
def download_update(version: str | None = None) -> DownloadResult
- Resolve target release:
releases/latestorGET /repos/{repo}/releases/tags/{version}for a specific version. - Pick asset, in priority order:
a.
evoscientist-X.Y.Z-py3-none-any.whl(fromassets[].name,browser_download_url) b. sdistEvoScientist-X.Y.Z.tar.gzc.tarball_urlfallback - Host allowlist: parsed asset URL host must equal the update base URL's
host (allow port
:8443); reject everything else (SSRF guard, mirrorvalidateDownloadURLatupdate_service.go:447). - Stream-download with 200 MB cap to
{workspace}/.evoscientist/updates/v{X.Y.Z}/(create dir;workspace=EVOSCIENTIST_WORKSPACE_DIR). - If the release has a
checksums.txtasset, download it and verify sha256 of the chosen file; fail hard on mismatch, delete the file. - Return
{version, file, path, suggested_command}wheresuggested_command = f"uv pip install {path} # then restart the backend".
- Route:
POST /internal/system/version/download, body{"version": optional}, timeout 120s.
Task 3.2: WebUI — BFF src/app/api/system/version/download/route.ts
(POST proxy), and in VersionBadge dropdown a "Download update" button
(disabled while running; shows spinner, then success path + copyable install
command; errorToast on failure, mirroring ErrorLogBell patterns).
Tests
Backend (EvoScientist/tests/ or colocated, pytest):
- semver compare:
v0.1.19 < 0.2.2, equal, malformed segments - 404 on
releases/latest→ falls back to tags → still 404 → latest=current - cache: second call within TTL does not hit network (mock httpx)
force=Truebypasses cache- download: asset priority (wheel over sdist), host allowlist rejects
evil.com, size cap aborts, checksum mismatch deletes file - disabled env var short-circuits
WebUI (vitest, node env — same as existing route.test.ts files):
- BFF GET proxies + forwards
force - BFF POST download proxies body
- Badge renders amber/dot only when
has_update
Rollout order
- Part 1 script + baseline release v0.2.2 (validates the publishing path)
- Task 2.1 + 2.2 (backend check) with tests
- Task 2.3 + 2.4 (WebUI badge), browser-verify with playwright
- Task 3.1 + 3.2 (download), verify a real v0.2.2 download end-to-end
Open risks
- Gitea instance may require auth for API reads in the future → checker should
accept optional
EVOSCIENTIST_UPDATE_TOKENheader from the start. - Release URLs embed
:8443; if the instance later moves to 443, allowlist logic must not hard-code the port.