Some checks failed
CI / lint (push) Failing after 1m8s
CI / notebooks-smoke (push) Successful in 2m5s
Deploy / changes (push) Successful in 18s
Infra CI / notebooks (push) Failing after 49s
CI / test (push) Successful in 5m5s
Infra CI / zotero (push) Successful in 39s
Infra CI / api (push) Successful in 27s
Infra CI / docs (push) Successful in 36s
Infra CI / bcda (push) Successful in 25s
Infra CI / llm (push) Successful in 23s
Infra CI / mc (push) Successful in 40s
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 / report (push) Has been cancelled
Deploy / notebooks (push) Has been cancelled
dev/scripts/marimo_latest.py, run by a nightly systemd user timer, moves
the notebooks image to the newest stable marimo release past the stack's
7-day cooldown: bump the Dockerfile pin, build, gate with nb_fe_smoke
--image (the overlay guards already fail the build if marimo's chrome
controls come back), commit just the pin on main and push, roll the
container and probe it live. A failure restores the pin (and HEAVY_TAG),
skips that release until a newer one, and files a Gitea issue. Renovate
was meant to bump this pin but has processed no repository in weeks
("No repositories found"), and nothing ever rolled its bumps anyway.
The image sets MARIMO_SKIP_UPDATE_CHECK=1, so marimo no longer polls
PyPI and the editor's "Update available" toast is gone.
331 lines
11 KiB
Python
331 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() -> None:
|
|
run(
|
|
[
|
|
"docker",
|
|
"compose",
|
|
"up",
|
|
"-d",
|
|
"--no-build",
|
|
"--pull",
|
|
"never",
|
|
"--no-deps",
|
|
"notebooks",
|
|
],
|
|
stage="roll",
|
|
)
|
|
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()
|
|
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()
|
|
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())
|