#!/usr/bin/env python3 # # A script that detect packages and creates a new release for them. # It makes a lot of assumptions so make sure all url's given in the # output makes sense before approving a release. # from abc import ABCMeta, abstractmethod from genericpath import exists import json from os import chdir import os import re from shutil import rmtree import subprocess import sys from tempfile import mkdtemp from typing import Dict, List, NoReturn, Optional from urllib.request import Request, urlopen def error(*args) -> NoReturn: print("\033[91m" + " ".join(args) + "\033[0m", file=sys.stderr) exit(1) def warn(*args) -> None: print("\033[93m" + " ".join(args) + "\033[0m", file=sys.stderr) def debug(*args: str) -> None: print("\033[94m" + " ".join(args) + "\033[0m") def info(*args: str) -> None: print("\033[92m" + " ".join(args) + "\033[0m") def exec(command: str) -> str: debug(f"Executing '{command}'") res = subprocess.run(command, capture_output=True, shell=True) if res.returncode != 0: error( f"Command failed with code {res.returncode}\n", res.stderr.decode("utf-8") ) return res.stdout.decode("utf-8").rstrip() def request(url: str, headers: Optional[Dict[str, str]] = None) -> str: if headers == None: headers = {} req = Request(url, headers=headers) with urlopen(req) as response: if response.status != 200: error(f"{url} returned {response.status}") return response.read().decode("utf-8") HOME = os.environ["HOME"] VERSION = sys.argv[1] pattern = "^[0-9]*\\.[0-9]*\\.[0-9]*$" if re.match(pattern, VERSION) == None: error("No version given") class Module(metaclass=ABCMeta): def name(self) -> str: return type(self).__name__ @abstractmethod def should_load(self) -> bool: "Should this module load" @abstractmethod def link(self) -> Optional[str]: """Give a link to the project""" @abstractmethod def validate(self) -> None: """Validate that the module has everything it needs to release, to ensure successful release""" def pre_release(self) -> None: pass @abstractmethod def release(self) -> None: """Make release on given module""" class Github(Module): def should_load(self): url = exec("git remote get-url origin") return "github.com" in url def link(self): return exec("gh browse -n") def validate(self): exec("gh auth status") def release(self): notes = input("\033[92mGithub Release notes: \033[0m") exec(f"gh release create {VERSION} --notes '{notes}'") class Aur(Module): def should_load(self): repo_name = exec("git remote get-url origin") git_username = exec("git config --get user.name") repo_name = repo_name.split("/")[-1].split(".")[0] data = request( f"https://aur.archlinux.org/rpc/?v=5&type=search&arg={repo_name}" ) data = json.loads(data) results = data["results"] filtered_results = [] for res in results: if res["Maintainer"].lower() == git_username.lower(): filtered_results.append(res) # Sort by popularity filtered_results.sort(key=lambda r: r["Popularity"]) if len(filtered_results) == 0: return False self.package = filtered_results[0].get("Name") self.maintainer = filtered_results[0].get("Maintainer") return True def link(self): return f"https://aur.archlinux.org/packages/{self.package}" def validate(self): # Check if can push to AUR tmp = mkdtemp() root_path = os.getcwd() chdir(tmp) exec(f"git clone ssh://aur@aur.archlinux.org/{self.package}.git .") exec("git push --dry-run") exec("makepkg --help") chdir(root_path) rmtree(tmp) def _sha256sums(self, pkgbuild: str) -> str: debug("Generating sha256sums") pkgbuild_lines = pkgbuild.splitlines() sha_lines = [] # all lines containing sha's check_ending = False for i, line in enumerate(pkgbuild_lines): if check_ending: sha_lines.append(i) if ")" in line: check_ending = False if line.startswith("sha256sums="): sha_lines.append(i) if ")" in line: break check_ending = True sum = exec("makepkg -g") # generate new sums # remove old sums sha_lines.reverse() # remove from big to small for i in sha_lines: del pkgbuild_lines[i] pkgbuild = "" # inject new sum in new pkgbuild for i, line in enumerate(pkgbuild_lines): if i == sha_lines[0] - 1: pkgbuild += sum + "\n" pkgbuild += line + "\n" return pkgbuild.rstrip() def release(self): tmp = mkdtemp() debug(f"Created {tmp}") root_path = os.getcwd() chdir(tmp) exec(f"git clone ssh://aur@aur.archlinux.org/{self.package}.git .") with open("PKGBUILD", "r") as file: pkgbuild: str = file.read() pkgbuild = re.sub( "^pkgver\\s*=.*", f"pkgver={VERSION}", pkgbuild, 1, re.MULTILINE ) pkgbuild = re.sub("^pkgrel\\s*=.*", f"pkgrel=1", pkgbuild, 1, re.MULTILINE) # write before generating sums with open("PKGBUILD", "w") as file: file.write(pkgbuild) if re.search("^sha256sums\\s*=", pkgbuild, re.MULTILINE): pkgbuild = self._sha256sums(pkgbuild) # write with sums with open("PKGBUILD", "w") as file: file.write(pkgbuild) exec("makepkg --printsrcinfo > .SRCINFO") exec("makepkg --check") # Ensure install works exec("git add PKGBUILD .SRCINFO") exec(f"git commit -m 'Release {VERSION}'") exec("git push") chdir(root_path) rmtree(tmp) class Cargo(Module): def should_load(self): return exists("Cargo.toml") def link(self): with open("Cargo.toml", "r") as file: name = re.search("^name\\s*=.*", file.read(), re.MULTILINE) if name is None: error("Could not find crate name") name = str(name.group(0)).split("=")[1].replace(" ", "").replace('"', "") return f"https://crates.io/crates/{name}" def validate(self): if not exists(f"{HOME}/.cargo/credentials"): error(f"{HOME}/.cargo/credentials does not exist") exec("cargo test") exec("cargo build --release --locked") exec("cargo publish --dry-run") def pre_release(self) -> None: debug("Updating version in Cargo.toml") with open("Cargo.toml", "r") as file: cargo_toml: str = file.read() cargo_toml = re.sub( "^version\\s*=.*", f'version = "{VERSION}"', cargo_toml, 1, re.MULTILINE ) with open("Cargo.toml", "w") as file: file.write(cargo_toml) if exec("git diff") != "": # Update lock file exec("cargo build --release --offline") exec("git add Cargo.toml Cargo.lock") exec(f"git commit -m 'Release {VERSION}'") exec("git push") def release(self): exec(f"cargo publish") def prepare_branch() -> str: """Returns the remote""" branch = "master" if "master" not in exec("git branch"): branch = "main" remote = exec(f"git config branch.{branch}.remote") exec(f"git switch {branch}") exec(f"git pull {remote} {branch}") exec(f"git push -u {remote} {branch}") return remote DEFAULT_MODULES = [Github(), Cargo(), Aur()] def release(): root = exec("git rev-parse --show-toplevel") debug(f"Moving to root '{root}'") chdir(root) if exec("git status --short") != "": error("Git branch is dirty") remote = prepare_branch() modules: List[Module] = [] for module in DEFAULT_MODULES: if module.should_load(): debug(f"Found {module.name()}") modules.append(module) for module in modules: module.validate() for module in modules: info(f"Will release on {module.name()} ({module.link()})") answer = input("\033[92mProceed with release? [Y/n] \033[0m") if answer.lower() != "y": error("") for module in modules: module.pre_release() exec(f"git tag {VERSION}") exec(f"git push {remote} --tags") for module in modules: module.release() info(f"Release {VERSION} finished") release()