Files
ekklesiaandClaude Opus 5.5 0f96019714 feat: gitea-release, a release record that never replaces published bytes
Gitea lets anyone with write access swap a release asset for another of
the same name. tools/gitea-release, which the workflows use for every
release write, uploads only under a name the release lacks, leaves
identical bytes alone, refuses different ones, never moves a tag, and
only appends to the record. Measured on a throwaway Gitea 1.27.2 with the
automatic Actions token; tested here against a stand-in API.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:47:42 -05:00

305 lines
9.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""The Gitea release that is the permanent record of one version.
A Gitea release is where a version's package, its checksum, how it was
built and what happened to it afterwards are kept for good: which commit,
which gate, when it went to Preview (canary), when it was promoted to
Stable and on whose approval. The update server is where classrooms fetch
it; this is where a person months from now finds out why.
PUBLISHED BYTES ARE NEVER REPLACED. Gitea lets anyone with write access
delete an asset and upload another under the same name, and nothing in
Gitea stops a workflow doing it either, so this does: an asset is only
ever uploaded under a name the release does not have yet. An asset that
is already there with exactly the same bytes is left alone and reported;
one with different bytes fails, loudly, and nothing is changed. The same
rule the update server has had since 0.2.6 was republished over itself
(tools/release-lib.sh).
A tag, likewise, is never moved: a release is only created for a tag that
does not exist, or that already names the commit being released.
The record is append-only. `record` adds a dated line under "## Record";
nothing here rewrites what an earlier line says.
gitea-release show TAG the release, or exit 3 if none
gitea-release notes TAG its notes and record, as text
gitea-release create TAG COMMIT TITLE NOTES_FILE [--prerelease]
gitea-release upload TAG FILE... each only if not there yet
gitea-release download TAG NAME DEST an asset, by name
gitea-release record TAG LINE append to the record
gitea-release stable TAG no longer a pre-release
Environment:
GITEA_URL https://git.riverofglorymn.org (GITHUB_SERVER_URL in CI)
GITEA_REPO River_of_Glory_Church/Ekklesia_Classroom (GITHUB_REPOSITORY)
GITEA_TOKEN a token that may write releases; never printed
"""
import hashlib
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timezone
RECORD = "## Record"
class Refused(Exception):
"""Something that must not happen, said in words."""
def say(message):
print(f"gitea-release: {message}", flush=True)
def config():
url = os.environ.get("GITEA_URL") or os.environ.get("GITHUB_SERVER_URL")
repo = os.environ.get("GITEA_REPO") or os.environ.get("GITHUB_REPOSITORY")
token = os.environ.get("GITEA_TOKEN", "")
if not url or not repo:
raise Refused("GITEA_URL and GITEA_REPO must be set")
return url.rstrip("/"), repo, token
def request(method, path, body=None, headers=None, raw=False):
"""(status, parsed body). 404 is an answer, not an error."""
url, repo, token = config()
target = path if path.startswith("http") else f"{url}/api/v1/repos/{repo}{path}"
headers = dict(headers or {})
if token:
headers["Authorization"] = f"token {token}"
if isinstance(body, (dict, list)):
body = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
call = urllib.request.Request(target, data=body, method=method, headers=headers)
try:
with urllib.request.urlopen(call, timeout=120) as response:
data = response.read()
status = response.status
except urllib.error.HTTPError as error:
data = error.read()
status = error.code
if status == 404:
return 404, None
if status >= 400:
detail = data[:300].decode("utf-8", "replace")
raise Refused(f"{method} {path} answered {status}: {detail}")
if raw:
return status, data
return status, json.loads(data) if data else None
def release(tag):
status, found = request("GET", f"/releases/tags/{urllib.parse.quote(tag)}")
return None if status == 404 else found
def need_release(tag):
found = release(tag)
if found is None:
raise Refused(f"there is no release {tag}")
return found
def tag_commit(tag):
status, found = request("GET", f"/tags/{urllib.parse.quote(tag)}")
if status == 404:
return None
return found["commit"]["sha"]
def sha256(data):
return hashlib.sha256(data).hexdigest()
def now():
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
def assets(found):
status, listed = request("GET", f"/releases/{found['id']}/assets")
return {} if status == 404 else {asset["name"]: asset for asset in listed or []}
def fetch(asset):
_status, data = request("GET", asset["browser_download_url"], raw=True)
return data
# ---------------------------------------------------------------------------
def show(tag):
found = release(tag)
if found is None:
say(f"no release {tag}")
return 3
print(json.dumps({
"tag": found["tag_name"],
"commit": tag_commit(tag),
"prerelease": found.get("prerelease"),
"assets": sorted(assets(found)),
"url": found.get("html_url"),
}, indent=2))
return 0
def notes(tag):
print(need_release(tag).get("body") or "")
return 0
def create(tag, commit, title, notes_file, prerelease):
notes = open(notes_file, encoding="utf-8").read()
at = tag_commit(tag)
if at is not None and at != commit:
raise Refused(f"tag {tag} already names {at}, not {commit}. "
"A tag is never moved; release a new version.")
found = release(tag)
if found is not None:
say(f"release {tag} already exists at {commit}; nothing created")
return 0
request("POST", "/releases", {
"tag_name": tag,
"target_commitish": commit,
"name": title,
"body": notes.rstrip() + f"\n\n{RECORD}\n\n- {now()}: release created at `{commit}`\n",
"draft": False,
"prerelease": prerelease,
})
if tag_commit(tag) != commit:
raise Refused(f"release {tag} was created, but its tag does not name {commit}")
say(f"created release {tag} at {commit}")
return 0
def upload(tag, files):
found = need_release(tag)
present = assets(found)
for path in files:
name = os.path.basename(path)
data = open(path, "rb").read()
digest = sha256(data)
if name in present:
there = sha256(fetch(present[name]))
if there == digest:
say(f"{name} is already attached, byte for byte; left alone")
continue
raise Refused(f"{name} is already attached to {tag} with sha256 {there}; "
f"this file is {digest}. A published asset is never replaced.")
boundary = uuid.uuid4().hex
body = (f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n").encode() \
+ data + f"\r\n--{boundary}--\r\n".encode()
request("POST", f"/releases/{found['id']}/assets?name={urllib.parse.quote(name)}",
body, {"Content-Type": f"multipart/form-data; boundary={boundary}"})
# Read back: what Gitea serves is what was sent.
there = assets(need_release(tag)).get(name)
if there is None or sha256(fetch(there)) != digest:
raise Refused(f"{name} was uploaded but does not read back as sha256 {digest}")
say(f"attached {name}, sha256 {digest}")
return 0
def download(tag, name, dest):
present = assets(need_release(tag))
if name not in present:
raise Refused(f"{tag} has no asset {name}")
data = fetch(present[name])
with open(dest, "wb") as handle:
handle.write(data)
say(f"{name} -> {dest}, sha256 {sha256(data)}")
return 0
def record(tag, line):
found = need_release(tag)
body = found.get("body") or ""
if RECORD not in body:
body = body.rstrip() + f"\n\n{RECORD}\n"
body = body.rstrip() + f"\n- {now()}: {line}\n"
request("PATCH", f"/releases/{found['id']}", {"body": body})
say(f"recorded on {tag}: {line}")
return 0
def stable(tag):
found = need_release(tag)
request("PATCH", f"/releases/{found['id']}", {"prerelease": False})
say(f"{tag} is no longer marked as a pre-release")
return 0
def main(argv):
try:
match argv:
case ["show", tag]:
return show(tag)
case ["notes", tag]:
return notes(tag)
case ["create", tag, commit, title, notes_file, *rest] if rest in ([], ["--prerelease"]):
return create(tag, commit, title, notes_file, rest == ["--prerelease"])
case ["upload", tag, *files] if files:
return upload(tag, files)
case ["download", tag, name, dest]:
return download(tag, name, dest)
case ["record", tag, line]:
return record(tag, line)
case ["stable", tag]:
return stable(tag)
case _:
print(__doc__, file=sys.stderr)
return 2
except Refused as refusal:
say(f"REFUSED: {refusal}")
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))