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
|
||||
Reference in new issue
Block a user