feat: dist upgrade --latest downloads and verifies the release from the feed, --expect pins the version (#161)
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:
1 parent
dd885db625
commit
cf892315e6
10 files changed
+888
-62
No files matched your search
+124
-5
@@ -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
|
||||
|
||||
Reference in new issue
Block a user