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
+115
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""A stand-in for the part of Gitea 1.27's API that tools/gitea-release uses.
|
||||
|
||||
Tags, releases and assets in memory. Writes need the token
|
||||
"not-a-real-token", as Gitea needs a real one. Like Gitea, it takes a
|
||||
second asset under a name a release already has: refusing that is
|
||||
tools/gitea-release's job, and the tests check that it does. GET
|
||||
/test/tamper/TAG/NAME changes an asset's bytes, the way somebody could in
|
||||
the web UI. Prints the port it listens on, then serves until killed.
|
||||
"""
|
||||
|
||||
import json, re
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import urlparse, parse_qs, unquote
|
||||
|
||||
TOKEN = "token not-a-real-token"
|
||||
PREFIX = "/api/v1/repos/church/classroom"
|
||||
state = {"tags": {"v0.1.5": "1" * 40}, "releases": [], "next": 1}
|
||||
|
||||
|
||||
def find(tag):
|
||||
return next((r for r in state["releases"] if r["tag_name"] == tag), None)
|
||||
|
||||
|
||||
class Gitea(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def reply(self, status, body=None, raw=None):
|
||||
data = raw if raw is not None else json.dumps(body).encode() if body is not None else b""
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def authorised(self):
|
||||
if self.headers.get("Authorization") != TOKEN:
|
||||
self.reply(401, {"message": "token is required"})
|
||||
return False
|
||||
return True
|
||||
|
||||
def release_json(self, r):
|
||||
port = self.server.server_port
|
||||
return {**{k: v for k, v in r.items() if k != "assets"},
|
||||
"html_url": f"http://127.0.0.1:{port}/church/classroom/releases/tag/{r['tag_name']}"}
|
||||
|
||||
def do_GET(self):
|
||||
path = urlparse(self.path).path
|
||||
if m := re.fullmatch(PREFIX + r"/releases/tags/(.+)", path):
|
||||
r = find(unquote(m.group(1)))
|
||||
return self.reply(200, self.release_json(r)) if r else self.reply(404, {})
|
||||
if m := re.fullmatch(PREFIX + r"/tags/(.+)", path):
|
||||
sha = state["tags"].get(unquote(m.group(1)))
|
||||
return self.reply(200, {"name": m.group(1), "commit": {"sha": sha}}) if sha else self.reply(404, {})
|
||||
if m := re.fullmatch(PREFIX + r"/releases/(\d+)/assets", path):
|
||||
r = next((r for r in state["releases"] if r["id"] == int(m.group(1))), None)
|
||||
if not r:
|
||||
return self.reply(404, {})
|
||||
port = self.server.server_port
|
||||
return self.reply(200, [{"id": i, "name": n, "size": len(d),
|
||||
"browser_download_url": f"http://127.0.0.1:{port}/download/{r['id']}/{n}"}
|
||||
for i, (n, d) in enumerate(r["assets"].items())])
|
||||
if m := re.fullmatch(r"/download/(\d+)/(.+)", path):
|
||||
r = next((r for r in state["releases"] if r["id"] == int(m.group(1))), None)
|
||||
data = r and r["assets"].get(unquote(m.group(2)))
|
||||
return self.reply(200, raw=data) if data is not None else self.reply(404, {})
|
||||
# For the test: somebody replaces an asset by hand in the web UI.
|
||||
if m := re.fullmatch(r"/test/tamper/(.+)/(.+)", path):
|
||||
find(unquote(m.group(1)))["assets"][unquote(m.group(2))] = b"tampered"
|
||||
return self.reply(200, {})
|
||||
return self.reply(404, {})
|
||||
|
||||
def do_POST(self):
|
||||
if not self.authorised():
|
||||
return
|
||||
url = urlparse(self.path)
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length)
|
||||
if url.path == PREFIX + "/releases":
|
||||
data = json.loads(body)
|
||||
if find(data["tag_name"]):
|
||||
return self.reply(409, {"message": "release exists"})
|
||||
state["tags"].setdefault(data["tag_name"], data["target_commitish"])
|
||||
r = {"id": state["next"], "tag_name": data["tag_name"], "name": data["name"],
|
||||
"body": data["body"], "prerelease": data["prerelease"], "draft": False, "assets": {}}
|
||||
state["next"] += 1
|
||||
state["releases"].append(r)
|
||||
return self.reply(201, self.release_json(r))
|
||||
if m := re.fullmatch(PREFIX + r"/releases/(\d+)/assets", url.path):
|
||||
r = next(r for r in state["releases"] if r["id"] == int(m.group(1)))
|
||||
name = parse_qs(url.query)["name"][0]
|
||||
boundary = self.headers["Content-Type"].split("boundary=")[1].encode()
|
||||
part = body.split(b"--" + boundary)[1]
|
||||
content = part.split(b"\r\n\r\n", 1)[1].rsplit(b"\r\n", 1)[0]
|
||||
# Gitea itself happily takes a second asset of the same name.
|
||||
r["assets"][name] = content
|
||||
return self.reply(201, {"name": name})
|
||||
return self.reply(404, {})
|
||||
|
||||
def do_PATCH(self):
|
||||
if not self.authorised():
|
||||
return
|
||||
path = urlparse(self.path).path
|
||||
if m := re.fullmatch(PREFIX + r"/releases/(\d+)", path):
|
||||
r = next(r for r in state["releases"] if r["id"] == int(m.group(1)))
|
||||
data = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
r.update({k: v for k, v in data.items() if k in ("body", "prerelease")})
|
||||
return self.reply(200, self.release_json(r))
|
||||
return self.reply(404, {})
|
||||
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Gitea)
|
||||
print(server.server_port, flush=True)
|
||||
server.serve_forever()
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
# The Gitea release record, against a stand-in for Gitea's API.
|
||||
#
|
||||
# Nothing here reaches the real Gitea. The stand-in keeps tags, releases
|
||||
# and assets in memory, answers the handful of calls tools/gitea-release
|
||||
# makes the way Gitea 1.27 answers them, and refuses any write that does
|
||||
# not carry the token. The questions are the ones that matter for a
|
||||
# permanent record: a tag is never moved, an asset's bytes are never
|
||||
# replaced, and the record only ever grows.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
SERVER_PID=""
|
||||
|
||||
cleanup() {
|
||||
[[ -z "$SERVER_PID" ]] || kill "$SERVER_PID" 2>/dev/null
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
passed=0
|
||||
failed=0
|
||||
|
||||
check() {
|
||||
local label="$1"
|
||||
shift
|
||||
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
echo " ok $label"
|
||||
passed=$((passed + 1))
|
||||
else
|
||||
echo " FAIL $label"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
refuses() {
|
||||
! "$@"
|
||||
}
|
||||
|
||||
|
||||
python3 "$ROOT/tests/fake-gitea.py" > "$WORK/port" 2>"$WORK/gitea.log" &
|
||||
SERVER_PID=$!
|
||||
|
||||
for _ in $(seq 1 50); do
|
||||
[[ -s "$WORK/port" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
export GITEA_URL="http://127.0.0.1:$(cat "$WORK/port")"
|
||||
export GITEA_REPO=church/classroom
|
||||
export GITEA_TOKEN=not-a-real-token
|
||||
RELEASE="$ROOT/tools/gitea-release"
|
||||
A=$(printf 'a%.0s' {1..40})
|
||||
B=$(printf 'b%.0s' {1..40})
|
||||
|
||||
|
||||
printf 'the package\n' > "$WORK/ekklesia-classroom_0.9.1_amd64.deb"
|
||||
printf '# 0.9.1\n\nNotes.\n' > "$WORK/notes.md"
|
||||
|
||||
echo "1. a release, created once, at one commit"
|
||||
check "no release yet is its own answer" \
|
||||
bash -c "'$RELEASE' show v0.9.1; [[ \$? -eq 3 ]]"
|
||||
check "it is created at the commit named" \
|
||||
"$RELEASE" create v0.9.1 "$A" "0.9.1" "$WORK/notes.md" --prerelease
|
||||
check "and its tag names that commit" \
|
||||
bash -c "[[ \$(curl -fsS '$GITEA_URL/api/v1/repos/church/classroom/tags/v0.9.1' | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"commit\"][\"sha\"])') == $A ]]"
|
||||
check "it is a pre-release until it is promoted" \
|
||||
bash -c "api() { curl -fsS '$GITEA_URL/api/v1/repos/church/classroom'\$1; }; api /releases/tags/v0.9.1 | grep -q '\"prerelease\": true'"
|
||||
check "creating it again at the same commit changes nothing" \
|
||||
"$RELEASE" create v0.9.1 "$A" "0.9.1" "$WORK/notes.md" --prerelease
|
||||
check "a tag is never moved to another commit" \
|
||||
refuses "$RELEASE" create v0.9.1 "$B" "0.9.1" "$WORK/notes.md"
|
||||
check "nor is a tag that already exists taken over" \
|
||||
refuses "$RELEASE" create v0.1.5 "$B" "0.1.5" "$WORK/notes.md"
|
||||
|
||||
echo "2. assets: attached once, never replaced"
|
||||
check "the package is attached" \
|
||||
"$RELEASE" upload v0.9.1 "$WORK/ekklesia-classroom_0.9.1_amd64.deb"
|
||||
check "and reads back as the same bytes" \
|
||||
bash -c "'$RELEASE' download v0.9.1 ekklesia-classroom_0.9.1_amd64.deb '$WORK/back.deb' \
|
||||
&& cmp -s '$WORK/back.deb' '$WORK/ekklesia-classroom_0.9.1_amd64.deb'"
|
||||
check "the same file again is left alone, and is not an error" \
|
||||
bash -c "'$RELEASE' upload v0.9.1 '$WORK/ekklesia-classroom_0.9.1_amd64.deb' | grep -q 'left alone'"
|
||||
mkdir -p "$WORK/rebuilt"
|
||||
printf 'a different build\n' > "$WORK/rebuilt/ekklesia-classroom_0.9.1_amd64.deb"
|
||||
check "different bytes under the same name are refused" \
|
||||
refuses "$RELEASE" upload v0.9.1 "$WORK/rebuilt/ekklesia-classroom_0.9.1_amd64.deb"
|
||||
check "and the published bytes are still the first ones" \
|
||||
bash -c "'$RELEASE' download v0.9.1 ekklesia-classroom_0.9.1_amd64.deb '$WORK/back2.deb' \
|
||||
&& cmp -s '$WORK/back2.deb' '$WORK/ekklesia-classroom_0.9.1_amd64.deb'"
|
||||
check "an asset the release does not have is refused, not invented" \
|
||||
refuses "$RELEASE" download v0.9.1 nothing.deb "$WORK/nothing"
|
||||
curl -fsS "$GITEA_URL/test/tamper/v0.9.1/ekklesia-classroom_0.9.1_amd64.deb" >/dev/null
|
||||
check "an asset changed by hand is caught at the next upload" \
|
||||
refuses "$RELEASE" upload v0.9.1 "$WORK/ekklesia-classroom_0.9.1_amd64.deb"
|
||||
|
||||
echo "3. the record only grows"
|
||||
check "a line is recorded" \
|
||||
"$RELEASE" record v0.9.1 "published to Preview (canary)"
|
||||
check "and another" \
|
||||
"$RELEASE" record v0.9.1 "promoted to Stable"
|
||||
check "the notes, the creation line and both records are all there, in order" \
|
||||
bash -c "curl -fsS '$GITEA_URL/api/v1/repos/church/classroom/releases/tags/v0.9.1' \
|
||||
| python3 -c 'import json,sys
|
||||
b = json.load(sys.stdin)[\"body\"]
|
||||
order = [b.index(t) for t in (\"Notes.\", \"## Record\", \"release created at\", \"Preview (canary)\", \"promoted to Stable\")]
|
||||
assert order == sorted(order), order
|
||||
assert b.count(\"## Record\") == 1'"
|
||||
check "the notes and record read back as text" \
|
||||
bash -c "'$RELEASE' notes v0.9.1 | grep -q 'Preview (canary)'"
|
||||
check "and asking for a release there is none of is refused" \
|
||||
refuses "$RELEASE" notes v0.0.0
|
||||
check "promoted, it is no longer a pre-release" \
|
||||
bash -c "'$RELEASE' stable v0.9.1 && curl -fsS '$GITEA_URL/api/v1/repos/church/classroom/releases/tags/v0.9.1' | grep -q '\"prerelease\": false'"
|
||||
|
||||
echo "4. credentials"
|
||||
check "a write without the token is refused by Gitea, and reported" \
|
||||
refuses env GITEA_TOKEN= "$RELEASE" record v0.9.1 "nobody"
|
||||
check "the token never appears in anything it prints" \
|
||||
bash -c "! { '$RELEASE' show v0.9.1; '$RELEASE' record v0.9.1 x; '$RELEASE' upload v0.9.1 '$WORK/rebuilt/ekklesia-classroom_0.9.1_amd64.deb'; } 2>&1 | grep -q not-a-real-token"
|
||||
check "nothing works without knowing which Gitea" \
|
||||
refuses env -u GITEA_URL -u GITHUB_SERVER_URL "$RELEASE" show v0.9.1
|
||||
|
||||
echo
|
||||
echo "$passed passed, $failed failed"
|
||||
[[ $failed -eq 0 ]]
|
||||
Reference in New Issue
Block a user