feat: dist upgrade --latest downloads and verifies the release from the feed, --expect pins the version (#161)
CI / verify (push) Successful in 1m42s
Release / release (push) Successful in 38s

Files changed:
- CHANGES.md
- INSTALL.md
- VERSION
- instructions/upgrade-instance.md
- tools/CONTRACT.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/tests/test_cli.py
- tools/chemenu/tests/test_dist_upgrade.py
- tools/chemenu/version.py
This commit is contained in:
torben committed 2026-09-29 22:08:00 +02:00
1 parent dd885db625
commit cf892315e6
10 files changed
+888 -62

No files matched your search

+124 -5
View File
@@ -37,7 +37,9 @@ import functools
import json
import os
import re
import shutil
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
@@ -349,12 +351,15 @@ def fetch_latest(
The network call sits behind `fetcher` so every caller above this line -
and every test - can run without a network. This is the one place that
talks to the **release feed**, and only two commands reach it: `version
check`, whose whole job it is, and `version notes` on a *distributed*
talks to the **release feed**, and only three commands reach it: `version
check`, whose whole job it is, `version notes` on a *distributed*
instance, whose local `CHANGES.md` is a stub with no entry to print (see
`fetch_latest_notes`). Neither is implicit - `check` exists for the call,
and `notes` announces the URL it is asking before it asks, on stderr, and
takes `--offline` for a caller that wants none of it.
`fetch_latest_notes`), and `dist upgrade --latest`, which asks the feed
which release to download (see `fetch_latest_assets`). None is implicit -
`check` exists for the call, `notes` announces the URL it is asking before
it asks, on stderr, and takes `--offline` for a caller that wants none of
it, and `upgrade` reaches the feed only when the operator passes
`--latest`.
"""
fetch = fetcher or _urlopen_fetch
try:
@@ -462,6 +467,120 @@ def fetch_latest_notes(
return version, body, (str(html_url) if html_url else None)
# --- release assets --------------------------------------------------------
#
# `dist upgrade --latest` downloads the two assets `.gitea/workflows/release.yml`
# attaches to every release. The names are that workflow's `name=` line plus
# `.tar.gz` / `.tar.gz.sha256`; `test_release_asset_names_match_the_workflow`
# ties this constant to the workflow so one cannot move without the other.
ARCHIVE_NAME = "chemenu-stack-{version}.tar.gz"
CHECKSUM_SUFFIX = ".sha256"
DOWNLOAD_TIMEOUT = 60.0
@dataclass(frozen=True)
class LatestRelease:
"""What the feed's latest release offers: its version, its tag as the feed
spells it, its own page, and every asset as `name -> download URL`."""
version: Version
tag: str
html_url: Optional[str]
assets: dict[str, str]
@property
def archive_name(self) -> str:
return ARCHIVE_NAME.format(version=self.version)
def asset_urls(self) -> tuple[str, str]:
"""`(archive URL, checksum URL)`, read off the release object and never
composed. Raises `VersionError` naming the release page when either is
missing - an upgrade without its checksum is refused, not downgraded to
an unchecked one."""
archive = self.archive_name
wanted = (archive, archive + CHECKSUM_SUFFIX)
missing = [name for name in wanted if name not in self.assets]
if missing:
present = ", ".join(sorted(self.assets)) or "none"
page = f" - see the release page {self.html_url}" if self.html_url else ""
raise VersionError(
f"release {self.tag} does not publish {' and '.join(missing)} "
f"(assets present: {present}){page}"
)
return self.assets[wanted[0]], self.assets[wanted[1]]
def fetch_latest_assets(
url: str,
token: Optional[str] = None,
timeout: float = 10.0,
fetcher: Optional[Fetcher] = None,
) -> LatestRelease:
"""`fetch_latest`'s version plus the release's assets, from the one
response - the asset URLs are the feed's own `browser_download_url`
values, never a path this code composes (AGENTS.md invariant 7)."""
version, captured = _fetch_latest_object(url, token, timeout, fetcher)
assets: dict[str, str] = {}
raw_assets = captured.get("assets")
if isinstance(raw_assets, list):
for entry in raw_assets:
if not isinstance(entry, dict):
continue
name = str(entry.get("name") or "").strip()
download = str(entry.get("browser_download_url") or "").strip()
if name and download:
assets[name] = download
html_url = captured.get("html_url") or captured.get("url")
return LatestRelease(
version=version,
tag=str(captured.get("tag_name") or "").strip(),
html_url=str(html_url) if html_url else None,
assets=assets,
)
def _origin(url: str) -> tuple[str, str, int]:
parts = urllib.parse.urlsplit(url)
scheme = parts.scheme.lower()
default = {"http": 80, "https": 443}.get(scheme, 0)
return scheme, (parts.hostname or "").lower(), parts.port or default
def token_for_asset(feed_url: str, asset_url: str, token: Optional[str]) -> Optional[str]:
"""The token to send with an asset download: `token` when the asset is on
the feed's own origin (scheme, host, port), `None` otherwise. An asset URL
is data from a JSON answer, so the credential does not follow it to
another host."""
return token if token and _origin(feed_url) == _origin(asset_url) else None
def download_asset(
url: str,
dest: Path,
token: Optional[str] = None,
timeout: float = DOWNLOAD_TIMEOUT,
) -> None:
"""Stream `url` into `dest`. `timeout` is a socket timeout per read, not a
deadline for the whole transfer. The token goes out as an *unredirected*
header, so a redirect to another host does not carry it along. Every
failure is a `VersionError` naming the URL."""
request = urllib.request.Request(url)
if token:
request.add_unredirected_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - URL from the feed
with dest.open("wb") as out:
shutil.copyfileobj(response, out)
except urllib.error.HTTPError as exc:
hint = ""
if exc.code in (401, 403):
hint = f" - the download needs authentication; set ${UPDATE_TOKEN_ENV}"
raise VersionError(f"{url} answered HTTP {exc.code}{hint}") from exc
except (urllib.error.URLError, OSError, TimeoutError) as exc:
raise VersionError(f"Could not download {url}: {exc}") from exc
# --- CHANGES.md ------------------------------------------------------------
#
# The changelog is prose and stays the author's job. What is mechanical is the