Files
theread.me/photo-upload/publisher.py
T

100 lines
4.2 KiB
Python

"""Commit queued submissions to GitHub; GitHub Actions builds and deploys."""
import fcntl
import json
import logging
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time
from app import atomic_json
log = logging.getLogger("publisher")
class Publisher:
def __init__(self):
self.data = Path(os.environ.get("PHOTO_DATA", "/data"))
self.work = Path(os.environ.get("PHOTO_WORK", "/work"))
self.repo = self.work / "repo"
self.remote = os.environ["PHOTO_REPOSITORY"]
self.branch = os.environ.get("PHOTO_BRANCH", "master")
for path in (self.work, self.data / "submissions", self.data / "status"):
path.mkdir(parents=True, exist_ok=True)
def git(self, *arguments, directory=None):
result = subprocess.run(["git", *arguments], cwd=directory or self.repo,
text=True, capture_output=True, timeout=300, check=True)
return result.stdout.strip()
def state(self, job, status, **extra):
atomic_json(self.data / "status" / f"{job}.json", dict(status=status, **extra))
def pending(self):
jobs = []
for path in sorted((self.data / "submissions").iterdir()):
status_path = self.data / "status" / f"{path.name}.json"
status = json.loads(status_path.read_text())["status"] if status_path.exists() else "queued"
if status in {"queued", "publishing"}:
jobs.append(path.name)
return jobs
def publish(self):
jobs = self.pending()
if not jobs:
return
for job in jobs:
self.state(job, "publishing")
try:
if not (self.repo / ".git").is_dir():
self.git("clone", "--branch", self.branch, "--single-branch", "--",
self.remote, str(self.repo), directory=self.work)
self.git("fetch", "origin", self.branch)
# Only reset this service-owned disposable checkout, never the user's checkout.
self.git("reset", "--hard", f"origin/{self.branch}")
self.git("config", "user.name", os.environ.get("PHOTO_GIT_NAME", "Photo publisher"))
self.git("config", "user.email", os.environ.get("PHOTO_GIT_EMAIL", "photos@theread.me"))
for job in jobs:
submission = self.data / "submissions" / job
manifest = json.loads((submission / "manifest.json").read_text())
entry = Path("_art") / f"{manifest['date'][:10]}-photo-{job}.md"
images = Path("img/arts/uploads") / job
(self.repo / entry).parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(submission / "entry.md", self.repo / entry)
shutil.copytree(submission / "images", self.repo / images, dirs_exist_ok=True)
self.git("add", "--", str(entry), str(images))
if self.git("diff", "--cached", "--name-only"):
self.git("commit", "-m", f"Publish {len(jobs)} photo submission(s)")
self.git("push", "origin", f"HEAD:refs/heads/{self.branch}")
commit = self.git("rev-parse", "HEAD")
for job in jobs:
self.state(job, "pushed", commit=commit)
log.info("Pushed %s submission(s) at %s", len(jobs), commit)
except (OSError, subprocess.SubprocessError) as error:
log.error("Git publication failed: %s", error)
if isinstance(error, subprocess.CalledProcessError):
log.error("%s", error.stderr)
for job in jobs:
self.state(job, "failed", error="Git push failed; check publisher logs and retry.")
raise
def run(self, once=False):
with (self.work / "publisher.lock").open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
while True:
try:
self.publish()
except (OSError, subprocess.SubprocessError):
if once:
raise
if once:
return
time.sleep(3)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
Publisher().run(once="--once" in sys.argv)