219 lines
12 KiB
Python
219 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Publish a verified build to Gitea and advance the metadata-only packwiz branch.
|
|
|
|
This command publishes externally. Run check/build/assemblePack, commit, and push
|
|
the source branch first. Git's configured credential helper supplies credentials
|
|
in memory; no token is accepted on the command line or written to disk.
|
|
"""
|
|
import argparse
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import quote, urlsplit, urlunsplit
|
|
from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
|
|
import uuid
|
|
import zipfile
|
|
|
|
sys.dont_write_bytecode = True
|
|
import pack
|
|
|
|
ROOT = pack.ROOT
|
|
CHANNEL = 'packwiz'
|
|
|
|
|
|
def git(*args, cwd=ROOT, input_text=None):
|
|
result = subprocess.run(['git', *args], cwd=cwd, input=input_text,
|
|
text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
pack.require(result.returncode == 0, 'Git operation failed: ' + ' '.join(args[:2]))
|
|
return result.stdout.strip()
|
|
|
|
|
|
class NoRedirect(HTTPRedirectHandler):
|
|
# Never forward an Authorization header to a redirected URL.
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
return None
|
|
|
|
|
|
class Gitea:
|
|
def __init__(self, origin):
|
|
parsed = urlsplit(pack.https_url(origin))
|
|
pack.require(not parsed.query, 'Git remote URL must not contain query parameters')
|
|
path = parsed.path.removesuffix('.git').strip('/')
|
|
pack.require(len(path.split('/')) == 2, 'Expected an owner/repository Git URL')
|
|
self.repo_url = f'https://{parsed.netloc}/{path}'
|
|
self.api_url = f'https://{parsed.netloc}/api/v1/repos/{path}'
|
|
result = subprocess.run(['git', 'credential', 'fill'], cwd=ROOT,
|
|
input='url=' + origin + '\n\n', text=True,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
pack.require(result.returncode == 0, 'Git credential helper did not supply credentials')
|
|
credentials = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
|
|
pack.require(credentials.get('username') and credentials.get('password'),
|
|
'Git credential helper needs a username and password/token')
|
|
token = credentials['username'] + ':' + credentials['password']
|
|
self.authorization = 'Basic ' + base64.b64encode(token.encode()).decode()
|
|
self.opener = build_opener(NoRedirect)
|
|
|
|
def request(self, endpoint, method='GET', value=None, missing_ok=False,
|
|
content_type='application/json'):
|
|
data = value if isinstance(value, bytes) else (
|
|
json.dumps(value).encode() if value is not None else None)
|
|
request = Request(self.api_url + endpoint, data=data, method=method,
|
|
headers={'Authorization': self.authorization,
|
|
'Accept': 'application/json', 'Content-Type': content_type})
|
|
try:
|
|
with self.opener.open(request, timeout=60) as response:
|
|
body = response.read()
|
|
return json.loads(body) if body else None
|
|
except HTTPError as error:
|
|
if missing_ok and error.code == 404:
|
|
return None
|
|
# Do not include request/response headers or credential-helper output.
|
|
raise SystemExit(f'Gitea {method} {endpoint.split("?")[0]} failed: HTTP {error.code}')
|
|
except URLError:
|
|
raise SystemExit('Gitea connection failed; no distribution channel was advanced.')
|
|
|
|
def upload(self, release, path):
|
|
filename = path.name
|
|
pack.require(not any(c in filename for c in '\r\n"'), 'Invalid asset filename')
|
|
existing = [asset for asset in release['assets'] if asset['name'] == filename]
|
|
pack.require(len(existing) <= 1, 'Duplicate release asset: ' + filename)
|
|
if existing:
|
|
asset = existing[0]
|
|
else:
|
|
boundary = 'sanctuary-' + uuid.uuid4().hex
|
|
payload = (f'--{boundary}\r\nContent-Disposition: form-data; '
|
|
f'name="attachment"; filename="{filename}"\r\n'
|
|
'Content-Type: application/octet-stream\r\n\r\n').encode()
|
|
payload += path.read_bytes() + f'\r\n--{boundary}--\r\n'.encode()
|
|
asset = self.request(f'/releases/{release["id"]}/assets?name={quote(filename)}',
|
|
'POST', payload,
|
|
content_type='multipart/form-data; boundary=' + boundary)
|
|
release['assets'].append(asset)
|
|
# Some reverse-proxied Gitea installations advertise HTTP despite their
|
|
# public HTTPS origin. Upgrade only this same-origin URL, then verify it.
|
|
advertised = urlsplit(asset['browser_download_url'])
|
|
if advertised.scheme == 'http' and advertised.netloc == urlsplit(self.repo_url).netloc:
|
|
advertised = advertised._replace(scheme='https')
|
|
asset_url = pack.https_url(urlunsplit(advertised))
|
|
# Verify anonymous access: players must not need publishing credentials.
|
|
with urlopen(asset_url, timeout=60) as response:
|
|
digest = hashlib.sha256(response.read()).hexdigest()
|
|
pack.require(digest == pack.sha256(path),
|
|
'Immutable release asset conflicts with local build: ' + filename)
|
|
return asset_url
|
|
|
|
|
|
def remote_tag_commit(tag):
|
|
refs = git('ls-remote', 'origin', 'refs/tags/' + tag, 'refs/tags/' + tag + '^{}')
|
|
found = dict(reversed(line.split('\t', 1)) for line in refs.splitlines())
|
|
return found.get('refs/tags/' + tag + '^{}') or found.get('refs/tags/' + tag)
|
|
|
|
|
|
def publish_channel(origin, version, source_head):
|
|
staged = ROOT / 'build/packwiz-release'
|
|
pack.check_index(staged)
|
|
files = [path for path in staged.rglob('*') if path.is_file()]
|
|
pack.require(all(path.suffix == '.toml' for path in files),
|
|
'Distribution branch must contain only generated TOML metadata')
|
|
remote = git('ls-remote', '--heads', 'origin', 'refs/heads/' + CHANNEL)
|
|
with tempfile.TemporaryDirectory(prefix='sanctuary-pack-channel-') as temp:
|
|
directory = Path(temp)
|
|
git('init', '--quiet', cwd=directory)
|
|
git('remote', 'add', 'origin', origin, cwd=directory)
|
|
# A source checkout may use Git's inferred identity without user.* config.
|
|
# Attribute generated metadata to the verified source commit's author.
|
|
git('config', 'user.name', git('show', '-s', '--format=%an', source_head), cwd=directory)
|
|
git('config', 'user.email', git('show', '-s', '--format=%ae', source_head), cwd=directory)
|
|
if remote:
|
|
git('fetch', '--quiet', 'origin', CHANNEL, cwd=directory)
|
|
git('checkout', '--quiet', '-b', CHANNEL, 'FETCH_HEAD', cwd=directory)
|
|
tracked = git('ls-files', cwd=directory).splitlines()
|
|
pack.require(all(name.endswith('.toml') for name in tracked),
|
|
'Existing distribution branch contains unexpected files')
|
|
for name in tracked:
|
|
(directory / name).unlink()
|
|
else:
|
|
git('checkout', '--quiet', '--orphan', CHANNEL, cwd=directory)
|
|
for path in files:
|
|
target = directory / path.relative_to(staged)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(path, target)
|
|
git('add', '--all', cwd=directory)
|
|
if git('status', '--porcelain', cwd=directory):
|
|
git('commit', '--quiet', '-m',
|
|
f'Publish Sanctuary {version} ({source_head[:12]})', cwd=directory)
|
|
# No force: reject a concurrent publication instead of overwriting it.
|
|
git('push', '--quiet', 'origin', 'HEAD:refs/heads/' + CHANNEL, cwd=directory)
|
|
else:
|
|
print('Distribution metadata already matches this release.')
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--notes-file', type=Path, help='UTF-8 release notes; otherwise a short build reference')
|
|
parser.add_argument('--asset', action='append', type=Path, default=[],
|
|
help='Additional public release asset, e.g. the generated .mrpack (repeatable)')
|
|
args = parser.parse_args()
|
|
pack.require(not git('status', '--porcelain'), 'Commit all source changes before publishing')
|
|
branch = git('symbolic-ref', '--quiet', '--short', 'HEAD')
|
|
pack.require(branch != CHANNEL, 'Publish from a source branch, not the distribution branch')
|
|
head = git('rev-parse', 'HEAD')
|
|
remote = git('ls-remote', '--heads', 'origin', 'refs/heads/' + branch)
|
|
pack.require(remote and remote.split()[0] == head, 'Push the current source branch before publishing')
|
|
values, _ = pack.check()
|
|
version = values['pack_version']
|
|
jar = ROOT / 'mods/sanctuary/build/libs' / ('sanctuary-' + values['mod_version'] + '.jar')
|
|
pack.require(jar.is_file(), 'Run ./gradlew check build assemblePack first')
|
|
with zipfile.ZipFile(jar) as archive:
|
|
mod = json.loads(archive.read('fabric.mod.json'))
|
|
pack.require(mod['id'] == 'sanctuary' and mod['version'] == values['mod_version'],
|
|
'Built JAR does not match the Sanctuary source version')
|
|
assets = [jar] + [path.resolve() for path in args.asset]
|
|
pack.require(all(path.is_file() for path in assets), 'A requested release asset is missing')
|
|
pack.require(len({path.name for path in assets}) == len(assets), 'Release asset names must be unique')
|
|
notes = args.notes_file.read_text(encoding='utf-8') if args.notes_file else (
|
|
f'Sanctuary {version}\n\nMinecraft {values["minecraft_version"]}; '
|
|
f'Fabric {values["loader_version"]}.\n\nSource: {head}.\n')
|
|
origin = git('remote', 'get-url', 'origin')
|
|
gitea = Gitea(origin)
|
|
tag = version
|
|
existing_commit = remote_tag_commit(tag)
|
|
pack.require(existing_commit is None or existing_commit == head,
|
|
'Release tag already belongs to another source commit; increment the version')
|
|
release = gitea.request('/releases/tags/' + quote(tag), missing_ok=True)
|
|
if release is None:
|
|
release = gitea.request('/releases', 'POST', {
|
|
'tag_name': tag, 'target_commitish': head, 'name': 'Sanctuary ' + version,
|
|
'body': notes, 'draft': False, 'prerelease': True,
|
|
})
|
|
pack.require(not release.get('draft'), 'The existing release is a draft; publish it explicitly first')
|
|
pack.require(remote_tag_commit(tag) == head, 'Release tag must resolve to the verified source commit')
|
|
jar_url = gitea.upload(release, jar)
|
|
for asset in assets[1:]:
|
|
gitea.upload(release, asset)
|
|
pack.assemble(jar_url)
|
|
publish_channel(origin, version, head)
|
|
stable_url = gitea.repo_url + '/raw/branch/' + CHANNEL + '/pack.toml'
|
|
with urlopen(stable_url, timeout=60) as response:
|
|
remote_manifest = response.read()
|
|
pack.require(remote_manifest == (ROOT / 'build/packwiz-release/pack.toml').read_bytes(),
|
|
'Published manifest differs from this build; inspect the distribution channel')
|
|
for path in (ROOT / 'build/packwiz-release').rglob('*.toml'):
|
|
relative = path.relative_to(ROOT / 'build/packwiz-release').as_posix()
|
|
with urlopen(stable_url.removesuffix('pack.toml') + quote(relative), timeout=60) as response:
|
|
published = response.read()
|
|
pack.require(published == path.read_bytes(), 'Published metadata differs: ' + relative)
|
|
print('Published Sanctuary ' + version)
|
|
print('Release: ' + gitea.repo_url + '/releases/tag/' + tag)
|
|
print('Packwiz: ' + stable_url)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|