Files
stack/dev/scripts/marimo_latest.py
kert 321bf93e5f
All checks were successful
CI / lint (push) Successful in 41s
CI / notebooks-smoke (push) Successful in 1m56s
Deploy / changes (push) Successful in 16s
CI / test (push) Successful in 3m26s
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 5m14s
Deploy / report (push) Successful in 16s
fix(renovate): land updates on main without PRs, daily, with github.com lookups
The 10-27 fix made Renovate process the repo, and a manual run proved it:
it opened 2 PRs and a Dependency Dashboard (#1396). But most lookups
failed on the anonymous api.github.com limit, it was capped at 2 new
branches an hour, and its PRs could never merge, because main still
required Woodpecker checks that stopped reporting when CI moved to Gitea
Actions. (Fixed in the Gitea branch protection: the CI lint/test/
notebooks-smoke push checks are now the required ones.)

- automergeType=branch: once CI is green, minor/patch updates
  fast-forward onto main as plain commits, with no PRs, matching the
  main-only workflow. Majors still open PRs for review;
  automergeStrategy=fast-forward keeps any PR merge free of merge commits.
- prHourlyLimit 0; the workflow runs daily, because branch automerge
  lands on the run after CI goes green.
- GITHUB_COM_TOKEN passed to Renovate (secret still to be added).
- Leave marimo to marimo_latest.py, hold duckdb <2 like the
  pyproject cap, and skip the vendored traefik plugin source.
- marimo_latest.py fast-forwards the checkout when Renovate has moved
  the server's main, instead of aborting.
2026-10-29 17:00:39 -04:00

345 lines
12 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":
# Renovate automerges onto the server's main. Fast-forward only: git
# refuses if that would touch files someone has modified here.
r = run(["git", "merge", "--ff-only", "gitea/main"], check=False)
if r.returncode:
raise Failed(
"preflight", f"can't fast-forward to gitea/main: {r.stderr[-300:]}"
)
log(f"fast-forwarded main to {git('rev-parse', '--short=7', 'HEAD')}")
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())