k9-mail/scripts/ci/setup_release_automation
2025-11-22 13:56:56 +01:00

628 lines
21 KiB
Python

#!/usr/bin/env python
# See docs/CI/Release_Automation.md for more details
# Run this from the .signing directory with all the keys and properties files in it.
# python -m venv venv; source venv/bin/activate; pip install requests pynacl
import os
import json
import base64
import argparse
import requests
import nacl.encoding
import nacl.public
import textwrap
PUBLISH_APPROVERS = ["kewisch", "coreycb", "wmontwe"]
CHANNEL_ENVIRONMENTS = {
"thunderbird_release": {
"branch": "release",
"variables": {
"RELEASE_TYPE": "release",
"MATRIX_INCLUDE": [
{
"appName": "thunderbird",
"releaseTarget": "ftp|github",
"packageFormat": "apk",
"packageFlavor": "foss",
},
{
"appName": "thunderbird",
"releaseTarget": "play",
"playTargetTrack": "internal",
"packageFormat": "aab",
"packageFlavor": "full",
},
{
"appName": "k9mail",
"releaseTarget": "ftp|github",
"packageFormat": "apk",
"packageFlavor": "foss",
},
{
"appName": "k9mail",
"releaseTarget": "play",
"playTargetTrack": "internal",
"packageFormat": "apk",
"packageFlavor": "full",
},
],
},
},
"thunderbird_beta": {
"branch": "beta",
"variables": {
"RELEASE_TYPE": "beta",
"MATRIX_INCLUDE": [
{
"appName": "thunderbird",
"releaseTarget": "ftp|github",
"packageFormat": "apk",
"packageFlavor": "foss",
},
{
"appName": "thunderbird",
"releaseTarget": "play",
"playTargetTrack": "internal",
"packageFormat": "aab",
"packageFlavor": "full",
},
],
},
},
"thunderbird_daily": {
"branch": "main",
"variables": {
"RELEASE_TYPE": "daily",
"MATRIX_INCLUDE": [
{
"appName": "thunderbird",
"releaseTarget": "ftp",
"packageFormat": "apk",
"packageFlavor": "foss",
},
{
"appName": "thunderbird",
"releaseTarget": "play",
"packageFormat": "aab",
"playTargetTrack": "internal",
"packageFlavor": "full",
},
],
},
},
}
SIGNING_ENVIRONMENTS = {
"k9mail_release_foss": {
"props": "k9.release.signing.properties",
"branch": "release",
},
"k9mail_release_full": {
"props": "k9.release.signing.properties",
"branch": "release",
},
"k9mail_beta_foss": {
"props": "k9.release.signing.properties",
"branch": "beta",
},
"k9mail_beta_full": {
"props": "k9.release.signing.properties",
"branch": "beta",
},
"thunderbird_daily_foss": {
"props": "tb.daily.signing.properties",
"branch": "main",
},
"thunderbird_daily_full": {
"props": "tb.daily.upload.properties",
"branch": "main",
},
"thunderbird_beta_foss": {
"props": "tb.beta.signing.properties",
"branch": "beta",
},
"thunderbird_beta_full": {
"props": "tb.beta.upload.properties",
"branch": "beta",
},
"thunderbird_release_foss": {
"props": "tb.release.signing.properties",
"branch": "release",
},
"thunderbird_release_full": {
"props": "tb.release.upload.properties",
"branch": "release",
},
}
# Function to read the key properties file
def read_key_properties(file_path):
key_properties = {}
with open(file_path, "r") as file:
for line in file:
if "=" in line:
key, value = line.strip().split("=", 1)
final_key = key.split(".")[-1]
key_properties[final_key] = value
return key_properties
# Function to base64 encode the .jks file
def encode_jks_file(jks_file_path):
with open(jks_file_path, "rb") as file:
encoded_key = base64.b64encode(file.read()).decode("utf-8")
return encoded_key
# Function to get the public key from GitHub for encryption
def get_github_public_key(repo, environment_name):
url = f"https://api.github.com/repos/{repo}/environments/{environment_name}/secrets/public-key"
headers = {
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json",
}
response = requests.get(url, headers=headers)
if response.status_code == 200:
data = response.json()
return [data["key_id"], data["key"]]
else:
raise Exception(
f"Failed to fetch public key from GitHub. Response: {response.status_code}, {response.text}"
)
# Function to encrypt a secret using the GitHub public key
def encrypt_secret(public_key: str, secret_value: str):
public_key_bytes = base64.b64decode(public_key)
sealed_box = nacl.public.SealedBox(nacl.public.PublicKey(public_key_bytes))
encrypted_secret = sealed_box.encrypt(secret_value.encode("utf-8"))
return base64.b64encode(encrypted_secret).decode("utf-8")
# Function to set encrypted secret in GitHub environment
def set_github_environment_secret(
repo, secret_name, encrypted_value, key_id, environment_name
):
url = f"https://api.github.com/repos/{repo}/environments/{environment_name}/secrets/{secret_name}"
headers = {
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json",
}
data = {"encrypted_value": encrypted_value, "key_id": key_id}
response = requests.put(url, headers=headers, json=data)
if response.status_code == 201:
print(f"\tSecret {secret_name} created successfully in {environment_name}.")
elif response.status_code == 204:
print(f"\tSecret {secret_name} updated successfully in {environment_name}.")
else:
raise Exception(
f"Failed to create secret {secret_name} in {environment_name}. Response: {response.status_code}, {response.text}"
)
def print_github_environment_variable(repo, environment_name):
url = (
f"https://api.github.com/repos/{repo}/environments/{environment_name}/variables"
)
headers = {
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json",
}
response = requests.get(url, headers=headers)
data = response.json()
if response.status_code == 200:
for variable in data["variables"]:
value = variable["value"]
if value[0] in "{[":
try:
value = textwrap.indent(
json.dumps(json.loads(value), indent=2), "\t\t"
).lstrip()
except:
pass
print(f"\t{variable['name']}={value}")
else:
raise Exception(
f"Unexpected response getting variables from {environment_name}: {response.status_code} {response.text}"
)
def set_github_environment_variable(repo, name, value, environment_name):
url = (
f"https://api.github.com/repos/{repo}/environments/{environment_name}/variables"
)
headers = {
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json",
}
data = {"name": name, "value": value}
response = requests.post(url, headers=headers, json=data)
if response.status_code == 201:
print(f"\tVariable {name} created successfully in {environment_name}.")
elif response.status_code == 409:
url = f"https://api.github.com/repos/{repo}/environments/{environment_name}/variables/{name}"
response = requests.patch(url, headers=headers, json=data)
if response.status_code == 204:
print(f"\tVariable {name} updated successfully in {environment_name}.")
else:
raise Exception(
f"Failed to update variable {name} in {environment_name}. Response: {response.status_code}, {response.text}"
)
else:
raise Exception(
f"Failed to create variable {name} in {environment_name}. Response: {response.status_code}, {response.text}"
)
def print_github_environment(repo, environment_name):
url = f"https://api.github.com/repos/{repo}/environments/{environment_name}"
headers = {
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json",
}
response = requests.get(url, headers=headers)
if response.status_code == 200:
data = response.json()
print(f"Environment {environment_name}")
print("\tProtection rules")
needs_branch_policies = False
for rule in data["protection_rules"]:
if rule["type"] == "branch_policy":
continue
print(f"\t\tType: {rule['type']}")
if rule["type"] == "required_reviewers":
reviewers = ", ".join(
map(
lambda reviewer: reviewer["reviewer"]["login"],
rule["reviewers"],
)
)
print(f"\t\t\tReviewers: {reviewers}")
print(f"\t\tBranch policy: {data['deployment_branch_policy']}")
if (
data["deployment_branch_policy"]
and data["deployment_branch_policy"]["custom_branch_policies"]
):
url += "/deployment-branch-policies"
response = requests.get(url, headers=headers)
if response.status_code == 200:
policies = map(
lambda policy: policy["name"], response.json()["branch_policies"]
)
print("\t\tBranches: " + ", ".join(policies))
else:
raise Exception(
f"Unexpected response getting variables from {environment_name}: {response.status_code} {response.text}"
)
# Function to create GitHub environment if it doesn't exist
def create_github_environment(repo, environment_name, branches=None, approvers=None):
url = f"https://api.github.com/repos/{repo}/environments/{environment_name}"
headers = {
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json",
}
data = {}
if branches:
data["deployment_branch_policy"] = {
"custom_branch_policies": True,
"protected_branches": False,
}
if approvers:
reviewers = map(
lambda approver: {
"type": "User",
"id": get_user_id_from_username(approver),
},
approvers,
)
data["reviewers"] = list(reviewers)
response = requests.put(url, headers=headers, json=data)
if response.status_code == 200:
print(f"Environment {environment_name} created successfully.")
elif response.status_code == 409:
print(f"Environment {environment_name} already exists.")
else:
raise Exception(
f"Failed to create environment {environment_name}. Response: {response.status_code}, {response.text}"
)
for branch in branches or []:
url = f"https://api.github.com/repos/{repo}/environments/{environment_name}/deployment-branch-policies"
data = {"name": branch, "type": "branch"}
response = requests.post(url, headers=headers, json=data)
if response.status_code == 200:
print(
f"\tBranch protection on {branch} for {environment_name} created successfully."
)
elif response.status_code == 409:
print(
f"\tBranch protection on {branch} for {environment_name} already exists."
)
else:
raise Exception(
f"Failed to create branch protection for {branch} on {environment_name}. Response: {response.status_code}, {response.text}"
)
# Function to get the GitHub user ID from a username
def get_user_id_from_username(username):
url = f"https://api.github.com/users/{username}"
headers = {
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json",
}
response = requests.get(url, headers=headers)
if response.status_code == 200:
user_data = response.json()
return user_data["id"]
else:
print(
f"Failed to fetch user ID for username '{username}'. Response: {response.status_code}, {response.text}"
)
return None
def create_approver_environment(repo, environment_name, approvers):
reviewers = map(
lambda approver: {"type": "User", "id": get_user_id_from_username(approver)},
approvers,
)
url = f"https://api.github.com/repos/{repo}/environments/{environment_name}"
headers = {
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json",
}
data = {"reviewers": list(reviewers)}
response = requests.put(url, headers=headers, json=data)
if response.status_code == 200:
print(f"Environment {environment_name} created successfully.")
elif response.status_code == 409:
print(f"Environment {environment_name} already exists.")
else:
raise Exception(
f"Failed to create environment {environment_name}. Response: {response.status_code}, {response.text}"
)
def create_signing_environment(repo, environment, branch, props_file):
# Read the key.properties file
key_props = read_key_properties(props_file)
KEY_ALIAS = key_props.get("keyAlias")
KEY_PASSWORD = key_props.get("keyPassword")
KEY_STORE_PASSWORD = key_props.get("storePassword")
if not all([KEY_ALIAS, KEY_PASSWORD, KEY_STORE_PASSWORD]):
print(
"Missing values in key.properties file. Please ensure all fields are present."
)
return
# Base64 encode the JKS file to create SIGNING_KEY
SIGNING_KEY = encode_jks_file(key_props.get("storeFile"))
# Create the environment if it doesn't exist
create_github_environment(repo, environment, branches=[branch])
# Fetch the public key from GitHub for the specific environment
key_id, public_key = get_github_public_key(repo, environment)
# Encrypt the secrets using the public key
encrypted_signing_key = encrypt_secret(public_key, SIGNING_KEY)
encrypted_key_alias = encrypt_secret(public_key, KEY_ALIAS)
encrypted_key_password = encrypt_secret(public_key, KEY_PASSWORD)
encrypted_key_store_password = encrypt_secret(public_key, KEY_STORE_PASSWORD)
# Set the encrypted secrets in the GitHub environment
secrets_to_set = {
"SIGNING_KEY": encrypted_signing_key,
"KEY_ALIAS": encrypted_key_alias,
"KEY_PASSWORD": encrypted_key_password,
"KEY_STORE_PASSWORD": encrypted_key_store_password,
}
for secret_name, encrypted_value in secrets_to_set.items():
set_github_environment_secret(
repo, secret_name, encrypted_value, key_id, environment
)
def make_bot_environment(repo, environment):
key_id, public_key = get_github_public_key(repo, environment)
with open("botmobile.key.pem") as fp:
encrypted_bot_key = encrypt_secret(public_key, fp.read())
with open("botmobile.clientid.txt") as fp:
bot_client_id = fp.read().strip()
with open("botmobile.userid.txt") as fp:
bot_user_id = fp.read().strip()
set_github_environment_secret(
repo, "BOT_PRIVATE_KEY", encrypted_bot_key, key_id, environment
)
set_github_environment_variable(repo, "BOT_CLIENT_ID", bot_client_id, environment)
set_github_environment_variable(repo, "BOT_USER_ID", bot_user_id, environment)
def create_channel_environment(repo, environment, branch, variables):
create_github_environment(repo, environment, branches=[branch])
for name, value in variables.items():
if isinstance(value, dict) or isinstance(value, list):
value = json.dumps(value)
set_github_environment_variable(repo, name, value, environment)
def create_release_environment(repo, branches):
environment = "publish_release"
create_github_environment(repo, environment, branches=branches)
key_id, public_key = get_github_public_key(repo, environment)
with open("play-store-account.json") as fp:
encrypted_play_account = encrypt_secret(public_key, fp.read())
set_github_environment_secret(
repo, "PLAY_STORE_ACCOUNT", encrypted_play_account, key_id, environment
)
def create_matrix_environment(repo, branches):
environment = "notify_matrix"
create_github_environment(repo, environment, branches=branches)
key_id, public_key = get_github_public_key(repo, environment)
with open("matrix-account.json") as fp:
mxdata = json.load(fp)
encrypted_token = encrypt_secret(public_key, mxdata["token"])
set_github_environment_secret(
repo, "MATRIX_NOTIFY_TOKEN", encrypted_token, key_id, environment
)
set_github_environment_variable(
repo, "MATRIX_NOTIFY_HOMESERVER", mxdata["homeserver"], environment
)
set_github_environment_variable(
repo, "MATRIX_NOTIFY_ROOM", mxdata["room"], environment
)
set_github_environment_variable(
repo, "MATRIX_NOTIFY_USER_MAP", json.dumps(mxdata["userMap"]), environment
)
def main():
# Argument parsing for positional inputs and repo flag
parser = argparse.ArgumentParser(
description="Set GitHub environment secrets for specific or all environments."
)
parser.add_argument(
"--repo",
"-r",
required=True,
help="GitHub repository in the format 'owner/repo'.",
)
parser.add_argument(
"--print", "-p", action="store_true", help="Print instead of set"
)
parser.add_argument(
"--skip", "-s", action="append", help="Skip this named environment"
)
parser.add_argument(
"--only", "-o", action="append", help="Only include this named environment"
)
args = parser.parse_args()
global GITHUB_TOKEN
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
if not GITHUB_TOKEN:
raise Exception(
"GITHUB_TOKEN environment variable is not set. Please set it before running the script."
)
if args.skip and args.only:
print("Error: Cannot supply both skip and only")
return
includeset = set(
list(CHANNEL_ENVIRONMENTS.keys())
+ list(SIGNING_ENVIRONMENTS.keys())
+ ["publish_hold", "publish_release", "notify_matrix", "botmobile"]
)
if args.skip:
for skip in args.skip:
includeset.remove(skip)
if args.only:
includeset = set(args.only)
# Publish hold environment
if "publish_hold" in includeset:
if args.print:
print_github_environment(args.repo, "publish_hold")
else:
create_github_environment(
args.repo, "publish_hold", approvers=PUBLISH_APPROVERS
)
# Channel environments
for environment_name, data in CHANNEL_ENVIRONMENTS.items():
if environment_name not in includeset:
continue
if args.print:
print(f"Environment {environment_name}")
print_github_environment_variable(args.repo, environment_name)
else:
create_channel_environment(args.repo, environment_name, **data)
make_bot_environment(args.repo, environment_name)
# Signing environments
for environment_name, data in SIGNING_ENVIRONMENTS.items():
if environment_name not in includeset:
continue
if args.print:
print_github_environment(args.repo, environment_name)
else:
if not os.path.exists(data["props"]):
print(f"Skipping {environment_name}: Missing key .properties file")
continue
create_signing_environment(
args.repo, environment_name, data["branch"], data["props"]
)
# Publish environment
if "publish_release" in includeset:
if args.print:
print_github_environment(args.repo, "publish_release")
else:
create_release_environment(args.repo, ["main", "beta", "release"])
make_bot_environment(args.repo, "publish_release")
# Botmobile environment
if "botmobile" in includeset:
if args.print:
print_github_environment(args.repo, "botmobile")
else:
create_github_environment(args.repo, "botmobile", branches=["main"])
make_bot_environment(args.repo, "botmobile")
# Notify
if "notify_matrix" in includeset:
if args.print:
print_github_environment(args.repo, "notify_matrix")
else:
create_matrix_environment(args.repo, ["main", "beta", "release"])
if __name__ == "__main__":
main()