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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5416112db5
commit
0f96019714
Executable
+304
@@ -0,0 +1,304 @@
|
||||
#!/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:]))
|
||||
Reference in New Issue
Block a user