Files
stack/dev/scripts/marimo_latest.py
kert 154fff36c2
Some checks failed
CI / lint (push) Failing after 55s
CI / notebooks-smoke (push) Successful in 1m58s
Deploy / changes (push) Successful in 15s
CI / test (push) Successful in 3m39s
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / bcda (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
Deploy / notebooks (push) Successful in 6m22s
Deploy / report (push) Successful in 17s
fix(notebooks): marimo_latest rolls the new tag, and proves it did
The first systemd run reported marimo 0.25.2 rolled while the container
stayed on the old image: the unit loads .env as EnvironmentFile, compose
prefers the process environment over .env, so the HEAVY_TAG the script
wrote was ignored and `compose up` recreated nothing. The health check
and live probe then passed against the old container.

roll_notebooks() now passes HEAVY_TAG to compose explicitly and fails
unless the running container's image carries the expected tag. Verified
under systemd-run with a stale HEAVY_TAG in the environment.
2026-10-29 16:39:09 -04:00

338 lines
11 KiB
Python

"""Keep the notebooks image on marimo's latest release.
Run nightly by the ``marimo-latest`` systemd user timer, from the main
checkout:
1. Find the newest stable marimo release on GitHub that is at least
``--cooldown-days`` old (default 7: the stack's release cooldown, as in
renovate.json's minimumReleaseAge and pyproject's exclude-newer).
2. If it is newer than ``ARG MARIMO_VERSION`` in
infra/images/notebooks.Dockerfile: bump the pin, build the image, gate it
with nb_fe_smoke.py --image (the overlay's own guards already fail the
build if marimo's chrome controls come back), commit the pin on main and
push, then roll the notebooks container (HEAVY_TAG = the new commit) and
probe the live service.
3. On failure the pin is restored (a live failure also rolls HEAVY_TAG
back), the version is recorded so later runs skip it until a newer
release, and a Gitea issue is filed.
A no-op when the pin is current; ``--dry-run`` only reports. Rolling ends
open marimo sessions, hence the early-morning timer.
Units live in ~/.config/systemd/user/ (like bib-refresh-iom):
marimo-latest.service Type=oneshot, WorkingDirectory=/home/kert/stack,
EnvironmentFile=/home/kert/stack/.env,
Environment=DOCKER_HOST=unix:///run/user/1000/docker.sock,
ExecStart=/usr/bin/python3 dev/scripts/marimo_latest.py
marimo-latest.timer OnCalendar=*-*-* 05:15:00, Persistent=true
Stdlib only.
"""
from __future__ import annotations
import argparse
import datetime as dt
import fcntl
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
DOCKERFILE = "infra/images/notebooks.Dockerfile"
# Everything the notebooks image COPYs; the build reads the working tree, so
# these must match HEAD or the image would carry edits the commit doesn't.
IMAGE_INPUTS = [DOCKERFILE, "pyproject.toml", "infra/marimo/theme"]
ZOTERO_INPUTS = ["infra/images/zotero.Dockerfile", "infra/images/zotero"]
STATE = ROOT / ".state" / "marimo-latest"
RELEASES_URL = "https://api.github.com/repos/marimo-team/marimo/releases?per_page=30"
ISSUES_URL = "https://git.fhirworx.io/api/v1/repos/homelab/stack/issues"
PIN_RE = re.compile(r"^(ARG MARIMO_VERSION=)(\S+)$", re.M)
STABLE_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
class Failed(Exception):
def __init__(self, stage: str, detail: str):
super().__init__(f"{stage}: {detail}")
self.stage, self.detail = stage, detail
def log(msg: str) -> None:
print(f"{dt.datetime.now():%Y-%m-%d %H:%M:%S} {msg}", flush=True)
def run(
cmd: list[str], *, stage: str = "", check: bool = True, **kw
) -> subprocess.CompletedProcess:
r = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, **kw)
if check and r.returncode:
raise Failed(stage or " ".join(cmd[:2]), (r.stdout + r.stderr)[-3000:])
return r
def git(*args: str, check: bool = True) -> str:
return run(["git", *args], check=check).stdout.strip()
def ver(tag: str) -> tuple[int, ...]:
return tuple(int(x) for x in STABLE_RE.match(tag).groups())
def latest_release(cooldown_days: int) -> str:
req = urllib.request.Request(
RELEASES_URL, headers={"Accept": "application/vnd.github+json"}
)
with urllib.request.urlopen(req, timeout=30) as resp:
releases = json.load(resp)
cutoff = dt.datetime.now(dt.UTC) - dt.timedelta(days=cooldown_days)
tags = [
r["tag_name"].lstrip("v")
for r in releases
if not (r["draft"] or r["prerelease"])
and STABLE_RE.match(r["tag_name"])
and dt.datetime.fromisoformat(r["published_at"]) <= cutoff
]
if not tags:
raise Failed("github", "no stable release past the cooldown")
return max(tags, key=ver)
def read_pin() -> str:
return PIN_RE.search((ROOT / DOCKERFILE).read_text()).group(2)
def write_pin(version: str) -> None:
p = ROOT / DOCKERFILE
p.write_text(PIN_RE.sub(rf"\g<1>{version}", p.read_text(), count=1))
def commit_pin(message: str) -> str:
# --only commits just the Dockerfile, whatever else sits in the shared
# index (concurrent sessions stage work in this checkout).
run(["git", "commit", "--only", "-m", message, "--", DOCKERFILE], stage="commit")
sha = git("rev-parse", "--short=7", "HEAD")
if run(["git", "push", "gitea", "main"], check=False).returncode:
log(f"WARN push failed; {sha} stays local until the next push")
return sha
def set_heavy_tag(tag: str) -> None:
env = ROOT / ".env"
env.write_text(
re.sub(r"^HEAVY_TAG=.*$", f"HEAVY_TAG={tag}", env.read_text(), flags=re.M)
)
def roll_notebooks(tag: str) -> None:
# Pass HEAVY_TAG explicitly: compose prefers the process environment over
# .env, and the systemd unit loads .env as EnvironmentFile at start, so
# without this compose keeps resolving the old tag and recreates nothing.
run(
[
"docker",
"compose",
"up",
"-d",
"--no-build",
"--pull",
"never",
"--no-deps",
"notebooks",
],
stage="roll",
env={**os.environ, "HEAVY_TAG": tag},
)
image = run(["docker", "inspect", "-f", "{{.Config.Image}}", "notebooks"]).stdout
if not image.strip().endswith(f"/notebooks:{tag}"):
raise Failed("roll", f"notebooks runs {image.strip()}, expected tag {tag}")
for _ in range(60):
status = run(
["docker", "inspect", "-f", "{{.State.Health.Status}}", "notebooks"],
check=False,
)
if status.stdout.strip() == "healthy":
return
time.sleep(3)
raise Failed("roll", "notebooks never reported healthy")
def retag_zotero(old: str, new: str) -> None:
"""HEAVY_TAG drives zotero too; carry its image over when its inputs match."""
same = (
run(
["git", "diff", "--quiet", old, "HEAD", "--", *ZOTERO_INPUTS], check=False
).returncode
== 0
)
have = (
run(
["docker", "image", "inspect", f"fhirworx/zotero:{old}"], check=False
).returncode
== 0
)
if same and have:
run(["docker", "tag", f"fhirworx/zotero:{old}", f"fhirworx/zotero:{new}"])
else:
log(
f"WARN zotero:{new} not tagged (inputs changed since {old} or image missing); build it before rolling zotero"
)
def file_issue(version: str, err: Failed) -> None:
token = os.environ.get("GITEA_TOKEN")
if not token:
log("WARN no GITEA_TOKEN; issue not filed")
return
body = json.dumps(
{
"title": f"marimo-latest: {version} failed at {err.stage}",
"body": (
f"`dev/scripts/marimo_latest.py` could not move the notebooks image to marimo "
f"{version}; the pin and the running image stay on the previous version, and "
f"later runs skip {version} until a newer release.\n\n```\n{err.detail[-2500:]}\n```"
),
}
)
r = run(
[
"curl",
"-sf",
"-X",
"POST",
"-H",
f"Authorization: token {token}",
"-H",
"Content-Type: application/json",
"--data-binary",
"@-",
ISSUES_URL,
],
check=False,
input=body,
)
log(
"filed Gitea issue"
if r.returncode == 0
else f"WARN issue filing failed: {r.stderr[:200]}"
)
def preflight() -> None:
if git("rev-parse", "--abbrev-ref", "HEAD") != "main":
raise Failed("preflight", "checkout is not on main")
if git("status", "--porcelain", "--", *IMAGE_INPUTS):
raise Failed("preflight", f"uncommitted changes in {', '.join(IMAGE_INPUTS)}")
git("fetch", "-q", "gitea", "main")
if git("rev-list", "--count", "HEAD..gitea/main") != "0":
raise Failed("preflight", "main is behind gitea/main; pull first")
def upgrade(old: str, new: str) -> None:
image = f"fhirworx/notebooks:marimo-{new}"
write_pin(new)
try:
log(f"building {image}")
run(
["docker", "build", "-f", DOCKERFILE, "-t", image, "."],
stage="build",
timeout=3600,
)
log("gating the image (nb_fe_smoke --image)")
run(
[
"python3",
"dev/scripts/nb_fe_smoke.py",
"--image",
image,
"--network",
"gateway",
],
stage="image gate",
)
sha = commit_pin(
f"chore(notebooks): marimo {old} → {new}\n\n"
"Bumped, built, gated and rolled by dev/scripts/marimo_latest.py."
)
except Exception:
write_pin(old)
raise
prev = re.search(r"^HEAVY_TAG=(.*)$", (ROOT / ".env").read_text(), re.M).group(1)
shutil.copy(ROOT / ".env", STATE / "env.bak")
run(["docker", "tag", image, f"fhirworx/notebooks:{sha}"])
retag_zotero(prev, sha)
log(f"rolling notebooks to {sha} (was {prev})")
set_heavy_tag(sha)
try:
roll_notebooks(sha)
run(
[
"python3",
"dev/scripts/nb_fe_smoke.py",
"--url",
"http://notebooks:2718",
"--network",
"gateway",
],
stage="live probe",
)
except Exception:
log(f"live probe failed; rolling back to {prev}")
set_heavy_tag(prev)
roll_notebooks(prev)
write_pin(old)
commit_pin(
f"revert(notebooks): marimo {new} → {old}\n\n"
f"The live probe failed after rolling {sha}; see the marimo-latest issue."
)
raise
log(f"notebooks on marimo {new} ({sha})")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--cooldown-days", type=int, default=7)
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
STATE.mkdir(parents=True, exist_ok=True)
lock = open(STATE / "lock", "w")
fcntl.flock(lock, fcntl.LOCK_EX)
failed_path = STATE / "failed.json"
failed = json.loads(failed_path.read_text()) if failed_path.exists() else {}
new, old = latest_release(args.cooldown_days), read_pin()
if ver(new) <= ver(old):
log(f"marimo {old} is current (latest past cooldown: {new})")
return 0
if new in failed:
log(f"skipping marimo {new}: failed before at {failed[new]}")
return 0
log(f"marimo {old} → {new}" + (" (dry run)" if args.dry_run else ""))
if args.dry_run:
return 0
try:
preflight()
upgrade(old, new)
except Failed as err:
log(f"FAILED {err.stage}: {err.detail[-800:]}")
if (
err.stage != "preflight"
): # a dirty checkout is transient, not the release's fault
failed[new] = err.stage
failed_path.write_text(json.dumps(failed, indent=2))
file_issue(new, err)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())