Add signed extension release workflow
This commit is contained in:
1 parent
6badceaf46
commit
9e997463da
10 files changed
+976
-38
No files matched your search
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build and verify a signed Chrome MV3 extension release."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
|
||||
from crx3 import creator, id_util, key_util, verifier
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
|
||||
DOWNLOAD_BASE = "https://git.komelt.dev/komelt/history-collector/releases/download"
|
||||
VERSION_RE = re.compile(r"^(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*)){0,3}$")
|
||||
TAG_RE = re.compile(
|
||||
r"^v((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*))?)$"
|
||||
)
|
||||
|
||||
|
||||
def chrome_version(value, label):
|
||||
if not isinstance(value, str) or not VERSION_RE.fullmatch(value):
|
||||
raise ValueError(f"{label} must contain 1 to 4 numeric Chrome version components")
|
||||
components = value.split(".")
|
||||
if any(int(component) > 65535 for component in components):
|
||||
raise ValueError(f"{label} contains a component greater than 65535")
|
||||
return value
|
||||
|
||||
|
||||
def release_version(tag):
|
||||
match = TAG_RE.fullmatch(tag)
|
||||
if not match:
|
||||
raise ValueError("tag must match vX.Y.Z or vX.Y.Z.N with numeric components")
|
||||
return chrome_version(match.group(1), "tag version")
|
||||
|
||||
|
||||
def read_manifest(path):
|
||||
try:
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"cannot read manifest: {error}") from error
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError("manifest must be a JSON object")
|
||||
return manifest
|
||||
|
||||
|
||||
def write_deterministic_zip(source, destination):
|
||||
with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for path in sorted(path for path in source.rglob("*") if path.is_file()):
|
||||
relative = path.relative_to(source).as_posix()
|
||||
info = zipfile.ZipInfo(relative, (1980, 1, 1, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
archive.writestr(info, path.read_bytes())
|
||||
|
||||
|
||||
def write_updates_xml(path, extension_id, version, tag):
|
||||
namespace = "http://www.google.com/update2/response"
|
||||
ET.register_namespace("", namespace)
|
||||
root = ET.Element(f"{{{namespace}}}gupdate", {"protocol": "2.0"})
|
||||
app = ET.SubElement(root, f"{{{namespace}}}app", {"appid": extension_id})
|
||||
ET.SubElement(
|
||||
app,
|
||||
f"{{{namespace}}}updatecheck",
|
||||
{
|
||||
"codebase": f"{DOWNLOAD_BASE}/{tag}/history-collector-{version}.crx",
|
||||
"version": version,
|
||||
},
|
||||
)
|
||||
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--tag", required=True)
|
||||
parser.add_argument("--private-key", required=True, type=Path)
|
||||
parser.add_argument("--extension-dir", type=Path, default=Path("extension"))
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
version = release_version(args.tag)
|
||||
extension_dir = args.extension_dir.resolve()
|
||||
manifest_path = extension_dir / "manifest.json"
|
||||
manifest = read_manifest(manifest_path)
|
||||
manifest_version = chrome_version(manifest.get("version"), "manifest version")
|
||||
if manifest_version != version:
|
||||
raise ValueError(f"tag version {version} does not match manifest version {manifest_version}")
|
||||
if not extension_dir.is_dir():
|
||||
raise ValueError(f"extension directory does not exist: {extension_dir}")
|
||||
|
||||
private_key_path = args.private_key.resolve()
|
||||
try:
|
||||
private_key_inside_extension = (
|
||||
os.path.commonpath((str(extension_dir), str(private_key_path))) == str(extension_dir)
|
||||
)
|
||||
except ValueError:
|
||||
private_key_inside_extension = False
|
||||
if private_key_inside_extension:
|
||||
raise ValueError("private key must not be inside the extension directory")
|
||||
try:
|
||||
private_key = key_util.load_private_key_from_pem(private_key_path.read_bytes())
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
raise ValueError(f"cannot load RSA private key: {error}") from error
|
||||
if not isinstance(private_key, rsa.RSAPrivateKey):
|
||||
raise ValueError("private key must be RSA")
|
||||
|
||||
public_key_der = key_util.get_public_key_data(private_key.public_key())
|
||||
extension_id = id_util.convert_hex_crx_id_to_alphabet(
|
||||
id_util.calc_crx_id_by_public_key(public_key_der).hex()
|
||||
)
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="history-collector-extension-") as temporary:
|
||||
staging = Path(temporary) / "extension"
|
||||
shutil.copytree(extension_dir, staging)
|
||||
staged_manifest = read_manifest(staging / "manifest.json")
|
||||
staged_manifest["version"] = version
|
||||
staged_manifest["key"] = base64.b64encode(public_key_der).decode("ascii")
|
||||
(staging / "manifest.json").write_text(
|
||||
json.dumps(staged_manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
zip_path = args.output_dir / f"history-collector-{version}.zip"
|
||||
crx_path = args.output_dir / f"history-collector-{version}.crx"
|
||||
updates_path = args.output_dir / "updates.xml"
|
||||
extension_id_path = args.output_dir / "extension-id.txt"
|
||||
checksums_path = args.output_dir / "checksums.txt"
|
||||
write_deterministic_zip(staging, zip_path)
|
||||
creator.create_crx_file(str(zip_path), str(private_key_path), str(crx_path))
|
||||
result, header = verifier.verify(str(crx_path))
|
||||
if result != verifier.VerifierResult.OK_FULL or header is None or header.crx_id != extension_id:
|
||||
raise ValueError("generated CRX3 failed verification or has the wrong extension ID")
|
||||
write_updates_xml(updates_path, extension_id, version, args.tag)
|
||||
extension_id_path.write_text(extension_id + "\n", encoding="ascii")
|
||||
checksum_paths = [zip_path, crx_path, updates_path, extension_id_path]
|
||||
checksums_path.write_text(
|
||||
"".join(f"{sha256(path)} {path.name}\n" for path in checksum_paths), encoding="ascii"
|
||||
)
|
||||
|
||||
print(f"Built and verified {args.tag} (extension ID {extension_id})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError) as error:
|
||||
raise SystemExit(f"build failed: {error}") from error
|
||||
@@ -0,0 +1,392 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish extension release assets through the Gitea REST API."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
VERSION_RE = re.compile(r"^(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*)){0,3}$")
|
||||
EXPECTED_CRX_CONTENT_TYPE = "application/x-chrome-extension"
|
||||
|
||||
|
||||
def _safe_error_detail(error, token):
|
||||
try:
|
||||
detail = error.read().decode("utf-8", errors="replace").strip()
|
||||
except (OSError, UnicodeError):
|
||||
detail = ""
|
||||
if token:
|
||||
detail = detail.replace(token, "[REDACTED]")
|
||||
return detail[:500]
|
||||
|
||||
|
||||
def api_call(base_url, token, method, path, payload=None, expected_errors=()):
|
||||
url = base_url.rstrip("/") + path
|
||||
body = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(request) as response:
|
||||
response_body = response.read()
|
||||
status = response.status
|
||||
except HTTPError as error:
|
||||
if error.code in expected_errors:
|
||||
return error.code, None
|
||||
detail = _safe_error_detail(error, token)
|
||||
suffix = f": {detail}" if detail else ""
|
||||
reason = str(error.reason).replace(token, "[REDACTED]") if token else str(error.reason)
|
||||
raise ApiError(f"Gitea API {method} {path}: HTTP {error.code} {reason}{suffix}") from None
|
||||
except URLError as error:
|
||||
detail = str(error.reason).replace(token, "[REDACTED]") if token else str(error.reason)
|
||||
raise ApiError(f"Gitea API {method} {path}: {detail}") from None
|
||||
if not response_body:
|
||||
return status, None
|
||||
try:
|
||||
return status, json.loads(response_body)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ApiError(f"Gitea API {method} {path}: invalid JSON response") from error
|
||||
|
||||
|
||||
def repository_path(repository):
|
||||
parts = repository.split("/", 1)
|
||||
if len(parts) != 2 or not all(parts):
|
||||
raise ValueError("GITEA_REPOSITORY must be OWNER/REPOSITORY")
|
||||
owner, name = (quote(part, safe="") for part in parts)
|
||||
return f"/repos/{owner}/{name}"
|
||||
|
||||
|
||||
def get_or_create_release(base_url, token, repo_path, tag, prerelease):
|
||||
encoded_tag = quote(tag, safe="")
|
||||
status, release = api_call(
|
||||
base_url, token, "GET", f"{repo_path}/releases/tags/{encoded_tag}", expected_errors=(404,)
|
||||
)
|
||||
if status == 404:
|
||||
_, release = api_call(
|
||||
base_url,
|
||||
token,
|
||||
"POST",
|
||||
f"{repo_path}/releases",
|
||||
{
|
||||
"tag_name": tag,
|
||||
"name": tag,
|
||||
"body": "Automated Chrome extension release.",
|
||||
"prerelease": prerelease,
|
||||
},
|
||||
)
|
||||
if not isinstance(release, dict) or not isinstance(release.get("id"), int):
|
||||
raise ApiError(f"Gitea API returned an invalid release for tag {tag}")
|
||||
return release
|
||||
|
||||
|
||||
def release_assets(base_url, token, repo_path, release_id):
|
||||
_, assets = api_call(base_url, token, "GET", f"{repo_path}/releases/{release_id}/assets")
|
||||
if not isinstance(assets, list):
|
||||
raise ApiError("Gitea API returned an invalid release asset list")
|
||||
return assets
|
||||
|
||||
|
||||
def _asset_path(repo_path, release_id, attachment_id):
|
||||
return f"{repo_path}/releases/{release_id}/assets/{attachment_id}"
|
||||
|
||||
|
||||
def _upload_asset(base_url, token, repo_path, release_id, path, asset_name):
|
||||
data = path.read_bytes()
|
||||
# A random boundary is also checked against the file so it cannot terminate the part.
|
||||
while True:
|
||||
boundary = f"----history-collector-{secrets.token_hex(16)}"
|
||||
if boundary.encode("ascii") not in data:
|
||||
break
|
||||
filename = path.name.replace("\\", "_").replace('"', "_").replace("\r", "_").replace("\n", "_")
|
||||
disposition_name = asset_name.replace("\\", "_").replace('"', "_").replace("\r", "_").replace("\n", "_")
|
||||
boundary_bytes = boundary.encode("ascii")
|
||||
body = b"--" + boundary_bytes + b"\r\n"
|
||||
body += f'Content-Disposition: form-data; name="attachment"; filename="{filename}"\r\n'.encode("utf-8")
|
||||
body += b"Content-Type: application/octet-stream\r\n\r\n" + data
|
||||
body += b"\r\n--" + boundary_bytes + b"--\r\n"
|
||||
query = urlencode({"name": disposition_name})
|
||||
url_path = f"{repo_path}/releases/{release_id}/assets?{query}"
|
||||
request = Request(
|
||||
base_url.rstrip("/") + url_path,
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(request) as response:
|
||||
response_body = response.read()
|
||||
status = response.status
|
||||
except HTTPError as error:
|
||||
detail = _safe_error_detail(error, token)
|
||||
suffix = f": {detail}" if detail else ""
|
||||
reason = str(error.reason).replace(token, "[REDACTED]") if token else str(error.reason)
|
||||
raise ApiError(f"Gitea API POST {url_path}: HTTP {error.code} {reason}{suffix}") from None
|
||||
except URLError as error:
|
||||
detail = str(error.reason).replace(token, "[REDACTED]") if token else str(error.reason)
|
||||
raise ApiError(f"Gitea API POST {url_path}: {detail}") from None
|
||||
if status < 200 or status >= 300:
|
||||
raise ApiError(f"Gitea API POST {url_path}: HTTP {status}")
|
||||
try:
|
||||
asset = json.loads(response_body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as error:
|
||||
raise ApiError(f"Gitea API POST {url_path}: invalid JSON response") from error
|
||||
if not isinstance(asset, dict) or not isinstance(asset.get("id"), int):
|
||||
raise ApiError(f"Gitea API POST {url_path}: invalid attachment response")
|
||||
return asset
|
||||
|
||||
|
||||
def _delete_asset(base_url, token, repo_path, release_id, attachment_id):
|
||||
api_call(base_url, token, "DELETE", _asset_path(repo_path, release_id, attachment_id))
|
||||
|
||||
|
||||
def _edit_asset(base_url, token, repo_path, release_id, attachment_id, name):
|
||||
api_call(
|
||||
base_url,
|
||||
token,
|
||||
"PATCH",
|
||||
_asset_path(repo_path, release_id, attachment_id),
|
||||
{"name": name},
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_asset(base_url, token, repo_path, release_id, attachment_id):
|
||||
if attachment_id is None:
|
||||
return None
|
||||
try:
|
||||
_delete_asset(base_url, token, repo_path, release_id, attachment_id)
|
||||
except ApiError as error:
|
||||
return str(error)
|
||||
return None
|
||||
|
||||
|
||||
def replace_asset(base_url, token, repo_path, release_id, path):
|
||||
existing = next(
|
||||
(
|
||||
asset
|
||||
for asset in release_assets(base_url, token, repo_path, release_id)
|
||||
if asset.get("name") == path.name and isinstance(asset.get("id"), int)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing is None:
|
||||
_upload_asset(base_url, token, repo_path, release_id, path, path.name)
|
||||
return
|
||||
|
||||
old_id = existing["id"]
|
||||
temporary_name = f".{path.name}.upload-{secrets.token_hex(12)}"
|
||||
backup_name = f".{path.name}.backup-{secrets.token_hex(12)}"
|
||||
temporary = _upload_asset(base_url, token, repo_path, release_id, path, temporary_name)
|
||||
temporary_id = temporary["id"]
|
||||
old_renamed = False
|
||||
try:
|
||||
_edit_asset(base_url, token, repo_path, release_id, old_id, backup_name)
|
||||
old_renamed = True
|
||||
try:
|
||||
_edit_asset(base_url, token, repo_path, release_id, temporary_id, path.name)
|
||||
except ApiError as error:
|
||||
restore_error = None
|
||||
try:
|
||||
_edit_asset(base_url, token, repo_path, release_id, old_id, path.name)
|
||||
except ApiError as restore_failure:
|
||||
restore_error = str(restore_failure)
|
||||
cleanup_error = _cleanup_asset(
|
||||
base_url, token, repo_path, release_id, temporary_id
|
||||
)
|
||||
details = [str(error)]
|
||||
if restore_error:
|
||||
details.append(f"could not restore previous asset: {restore_error}")
|
||||
if cleanup_error:
|
||||
details.append(f"could not remove temporary asset: {cleanup_error}")
|
||||
raise ApiError("; ".join(details)) from None
|
||||
|
||||
cleanup_error = _cleanup_asset(base_url, token, repo_path, release_id, old_id)
|
||||
if cleanup_error:
|
||||
raise ApiError(f"asset replaced, but could not remove backup asset: {cleanup_error}")
|
||||
except ApiError:
|
||||
if not old_renamed:
|
||||
cleanup_error = _cleanup_asset(base_url, token, repo_path, release_id, temporary_id)
|
||||
if cleanup_error:
|
||||
raise ApiError(f"asset replacement failed; could not remove temporary asset: {cleanup_error}") from None
|
||||
raise
|
||||
|
||||
|
||||
def _chrome_version(value, label):
|
||||
if not isinstance(value, str) or not VERSION_RE.fullmatch(value):
|
||||
raise ApiError(f"{label} must contain 1 to 4 numeric Chrome version components")
|
||||
components = tuple(int(component) for component in value.split("."))
|
||||
if any(component > 65535 for component in components):
|
||||
raise ApiError(f"{label} contains a component greater than 65535")
|
||||
return components + (0,) * (4 - len(components))
|
||||
|
||||
|
||||
def should_publish_updates(existing_version, candidate_version):
|
||||
if existing_version is None:
|
||||
return True
|
||||
return _chrome_version(candidate_version, "candidate version") > _chrome_version(
|
||||
existing_version, "existing updates.xml version"
|
||||
)
|
||||
|
||||
|
||||
def _updates_version(xml_body):
|
||||
try:
|
||||
root = ET.fromstring(xml_body)
|
||||
except ET.ParseError as error:
|
||||
raise ApiError(f"existing updates.xml is not valid XML: {error}") from error
|
||||
for element in root.iter():
|
||||
if element.tag.rsplit("}", 1)[-1] == "updatecheck" and "version" in element.attrib:
|
||||
version = element.attrib["version"]
|
||||
_chrome_version(version, "existing updates.xml version")
|
||||
return version
|
||||
raise ApiError("existing updates.xml has no updatecheck version")
|
||||
|
||||
|
||||
def _public_asset_url(base_url, repo_path, asset):
|
||||
for key in ("browser_download_url", "download_url"):
|
||||
if isinstance(asset.get(key), str) and asset[key]:
|
||||
return asset[key]
|
||||
api_suffix = "/api/v1"
|
||||
if base_url.rstrip("/").endswith(api_suffix):
|
||||
server_url = base_url.rstrip("/")[:-len(api_suffix)]
|
||||
return f"{server_url}{repo_path}/releases/download/updates/{quote(asset['name'], safe='')}"
|
||||
raise ApiError("Gitea did not provide a public download URL for existing updates.xml")
|
||||
|
||||
|
||||
def _download_anonymously(url, purpose):
|
||||
request = Request(url, headers={"Accept": "application/xml"}, method="GET")
|
||||
try:
|
||||
with urlopen(request) as response:
|
||||
return response.read()
|
||||
except HTTPError as error:
|
||||
raise ApiError(f"anonymous {purpose} request: HTTP {error.code} {error.reason}") from None
|
||||
except URLError as error:
|
||||
raise ApiError(f"anonymous {purpose} request failed: {error.reason}") from None
|
||||
|
||||
|
||||
def existing_updates_version(base_url, token, repo_path, release_id):
|
||||
asset = next(
|
||||
(
|
||||
asset
|
||||
for asset in release_assets(base_url, token, repo_path, release_id)
|
||||
if asset.get("name") == "updates.xml"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if asset is None:
|
||||
return None
|
||||
return _updates_version(
|
||||
_download_anonymously(_public_asset_url(base_url, repo_path, asset), "existing updates.xml")
|
||||
)
|
||||
|
||||
|
||||
def _updates_codebase(xml_path):
|
||||
try:
|
||||
root = ET.parse(xml_path).getroot()
|
||||
except (OSError, ET.ParseError) as error:
|
||||
raise ApiError(f"cannot read generated updates.xml: {error}") from error
|
||||
for element in root.iter():
|
||||
if element.tag.rsplit("}", 1)[-1] == "updatecheck":
|
||||
codebase = element.get("codebase")
|
||||
if codebase:
|
||||
return codebase
|
||||
raise ApiError("generated updates.xml has no codebase URL")
|
||||
|
||||
|
||||
def verify_public_crx(updates_path):
|
||||
codebase = _updates_codebase(updates_path)
|
||||
request = Request(codebase, headers={"Accept": "application/octet-stream"}, method="GET")
|
||||
try:
|
||||
with urlopen(request) as response:
|
||||
status = response.status
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
magic = response.read(4)
|
||||
except HTTPError as error:
|
||||
raise ApiError(
|
||||
f"CRX codebase URL is not anonymously reachable (HTTP {error.code}); "
|
||||
"make the release public and configure its MIME type"
|
||||
) from None
|
||||
except URLError as error:
|
||||
raise ApiError(
|
||||
f"CRX codebase URL is not anonymously reachable ({error.reason}); "
|
||||
"make the release public and configure its MIME type"
|
||||
) from None
|
||||
if status != 200:
|
||||
raise ApiError(f"CRX codebase URL returned HTTP {status}; make the release public")
|
||||
if magic != b"Cr24":
|
||||
raise ApiError("CRX codebase URL did not return a CRX3 file (missing Cr24 magic)")
|
||||
if content_type.split(";", 1)[0].strip().lower() != EXPECTED_CRX_CONTENT_TYPE:
|
||||
raise ApiError(
|
||||
"CRX codebase URL returned an invalid Content-Type; configure Gitea "
|
||||
"[repository.mimetype_mapping] or a reverse proxy .crx header as "
|
||||
"application/x-chrome-extension"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--tag", required=True)
|
||||
parser.add_argument("--directory", required=True, type=Path)
|
||||
parser.add_argument("--repository", default=os.environ.get("GITEA_REPOSITORY"))
|
||||
parser.add_argument("--api-url", default=os.environ.get("GITEA_API_URL"))
|
||||
args = parser.parse_args()
|
||||
token = os.environ.get("GITEA_TOKEN")
|
||||
server_url = os.environ.get("GITEA_SERVER_URL")
|
||||
api_url = args.api_url or (f"{server_url.rstrip('/')}/api/v1" if server_url else None)
|
||||
if not token:
|
||||
raise ValueError("GITEA_TOKEN is required")
|
||||
if not api_url:
|
||||
raise ValueError("GITEA_API_URL or GITEA_SERVER_URL is required")
|
||||
if not args.repository:
|
||||
raise ValueError("GITEA_REPOSITORY is required")
|
||||
if not args.tag.startswith("v"):
|
||||
raise ValueError("tag must start with v")
|
||||
|
||||
version = args.tag[1:]
|
||||
_chrome_version(version, "tag version")
|
||||
repo_path = repository_path(args.repository)
|
||||
directory = args.directory
|
||||
assets = {
|
||||
"crx": directory / f"history-collector-{version}.crx",
|
||||
"zip": directory / f"history-collector-{version}.zip",
|
||||
"checksums": directory / "checksums.txt",
|
||||
"extension_id": directory / "extension-id.txt",
|
||||
"updates": directory / "updates.xml",
|
||||
}
|
||||
if any(not path.is_file() for path in assets.values()):
|
||||
raise ValueError("build directory is missing one or more release assets")
|
||||
|
||||
release = get_or_create_release(api_url, token, repo_path, args.tag, prerelease=False)
|
||||
for key in ("crx", "zip", "checksums", "extension_id"):
|
||||
replace_asset(api_url, token, repo_path, release["id"], assets[key])
|
||||
# Verify the URL emitted in the new XML only after the versioned CRX is public.
|
||||
verify_public_crx(assets["updates"])
|
||||
|
||||
updates_release = get_or_create_release(api_url, token, repo_path, "updates", prerelease=True)
|
||||
old_version = existing_updates_version(api_url, token, repo_path, updates_release["id"])
|
||||
if should_publish_updates(old_version, version):
|
||||
replace_asset(api_url, token, repo_path, updates_release["id"], assets["updates"])
|
||||
print(f"Published {args.tag} and updates.xml")
|
||||
else:
|
||||
print(f"Published {args.tag}; kept existing updates.xml version {old_version}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (ApiError, OSError, ValueError) as error:
|
||||
raise SystemExit(f"publish failed: {error}") from error
|
||||
@@ -0,0 +1,103 @@
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from scripts import publish_release
|
||||
|
||||
|
||||
class Response:
|
||||
def __init__(self, body=b'{"id": 1}', status=201, headers=None):
|
||||
self.body = body
|
||||
self.status = status
|
||||
self.headers = headers or {}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def read(self, size=-1):
|
||||
return self.body if size < 0 else self.body[:size]
|
||||
|
||||
|
||||
class PublishReleaseTest(unittest.TestCase):
|
||||
def test_upload_is_multipart_with_attachment_field(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
path = Path(temporary) / "package.crx"
|
||||
path.write_bytes(b"Cr24\x03payload")
|
||||
with patch("scripts.publish_release.urlopen", return_value=Response()) as request:
|
||||
publish_release._upload_asset(
|
||||
"https://gitea.example/api/v1", "secret-token", "/repos/o/r", 4, path, "package.crx"
|
||||
)
|
||||
sent = request.call_args.args[0]
|
||||
body = sent.data
|
||||
content_type = sent.get_header("Content-type")
|
||||
boundary = re.search(r"boundary=([^;]+)", content_type).group(1).encode("ascii")
|
||||
self.assertIn(b'name="attachment"', body)
|
||||
self.assertIn(b"filename=\"package.crx\"", body)
|
||||
self.assertIn(b"Cr24\x03payload", body)
|
||||
self.assertTrue(body.endswith(b"--" + boundary + b"--\r\n"))
|
||||
|
||||
def test_edit_and_delete_use_release_scoped_attachment_path(self):
|
||||
with patch("scripts.publish_release.api_call") as call:
|
||||
publish_release._edit_asset("base", "token", "/repos/o/r", 9, 12, "new.crx")
|
||||
publish_release._delete_asset("base", "token", "/repos/o/r", 9, 12)
|
||||
self.assertEqual(call.call_args_list[0].args[3], "/repos/o/r/releases/9/assets/12")
|
||||
self.assertEqual(call.call_args_list[1].args[3], "/repos/o/r/releases/9/assets/12")
|
||||
|
||||
def test_public_crx_check_is_anonymous_and_allows_content_type_parameters(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
updates = Path(temporary) / "updates.xml"
|
||||
updates.write_text(
|
||||
'<gupdate><app><updatecheck codebase="https://public.example/package.crx" /></app></gupdate>',
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch(
|
||||
"scripts.publish_release.urlopen",
|
||||
return_value=Response(
|
||||
b"Cr24\x03payload",
|
||||
status=200,
|
||||
headers={"Content-Type": "application/x-chrome-extension; charset=binary"},
|
||||
),
|
||||
) as request:
|
||||
publish_release.verify_public_crx(updates)
|
||||
self.assertIsNone(request.call_args.args[0].get_header("Authorization"))
|
||||
|
||||
def test_second_replacement_is_single_asset_and_version_downgrades_are_skipped(self):
|
||||
assets = []
|
||||
next_id = iter(range(1, 10))
|
||||
|
||||
def upload(*args):
|
||||
asset = {"id": next(next_id), "name": args[-1]}
|
||||
assets.append(asset)
|
||||
return asset
|
||||
|
||||
def edit(*args):
|
||||
attachment_id, name = args[-2:]
|
||||
next(asset for asset in assets if asset["id"] == attachment_id)["name"] = name
|
||||
|
||||
def delete(*args):
|
||||
attachment_id = args[-1]
|
||||
assets[:] = [asset for asset in assets if asset["id"] != attachment_id]
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
path = Path(temporary) / "package.crx"
|
||||
path.write_bytes(b"data")
|
||||
with patch("scripts.publish_release.release_assets", side_effect=lambda *args: assets.copy()), \
|
||||
patch("scripts.publish_release._upload_asset", side_effect=upload), \
|
||||
patch("scripts.publish_release._edit_asset", side_effect=edit), \
|
||||
patch("scripts.publish_release._delete_asset", side_effect=delete):
|
||||
publish_release.replace_asset("base", "token", "/repos/o/r", 9, path)
|
||||
publish_release.replace_asset("base", "token", "/repos/o/r", 9, path)
|
||||
|
||||
self.assertEqual(assets, [{"id": 2, "name": "package.crx"}])
|
||||
self.assertFalse(publish_release.should_publish_updates("2.4.0", "2.3.9"))
|
||||
self.assertFalse(publish_release.should_publish_updates("2.4.0", "2.4.0"))
|
||||
self.assertTrue(publish_release.should_publish_updates("2.4.0", "2.4.1"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user