Files
deskflow/scripts/lib/mac.py
Nick Bolton 865063b77c Re-implement packaging for GitHub workflows (macOS) (#7353)
* Restore Azure macOS dist scripts

* Move steps to workflow for testing

* Always upload to GitHub

* Add codesign ID

* Echo codesign ID

* Add cert import code

* Stub file for Mac

* Self-install pyyaml and choco

* Auto add env var on Windows

* Auto add CMAKE_PREFIX_PATH to .zshrc

* Shorter var names

* Append env var instead of replace

* Only set env var if not CI

* Improve function names and print output

* Simplify Linux package command

* Support continuation sequence

* Add note about Windows

* Remove dead doc file

* Tidy up version file and move to .env format

* Use Python venv for deps

* Only use venv on Mac

* Rename package script for all OS

* Add package and dist steps, and use common upload

* Remove version source

* Fixed vars not available

* Fixed python paths

* Use RuntimeError which is sufficient

* Remove dead code

* Add extras command for Linux

* Always install deps on Linux

* Move Python deps to CI

* More env bootstrapping, ugh

* Forgot to return!

* Simplify code

* Use shell

* Simplify command

* Skip sudo if no sudo

* Update package managers

* Fixed Fedora package name

* Tidy up commands

* Use newer upload artifact

* Strip don't trim!

* Check for version file and reduce log verbosity

* Remove CentOS 7.6

* Print more info about return code and log more to stderr

* Install certificate on macOS

* Better errors for no env var

* Implement Mac signing and notary

* Move dmgbuild load

* Simplify notary

* Rename dist files to same as dest

* Fixed paths for dist

* Move checked-in dist files to res (dist is meant to be a temp dir)

* Fixed Mac path in CMake

* Fixed dmg path

* Format Python

* Ignore import warnings and move function

* Fixed cmake paths

* Add missing env var secrets

* Remove extensions from GH upload

* Make deps.yml general purpose config

* Add cspell config

* Pass codesign ID

* Use new general config file

* Sign bundle on Mac

* Move imports to functions

* Escape chars in docs

* Fixed config key accessor

* Change module import order

* Move file to tmp dir in workflow dir

* Persist temp dir

* Add tmp dir to ignore

* Flush stdio before running process

* Trying quotes around env values

* Add codesigning certificate validation for Mac signing

* Revert "Trying quotes around env values"

This reverts commit 0dd741e8cd6fde21e69d4fb871e835a5f4fa1a23.

* Extract codesign verify

* Fixed version number

* Ignore .cache dir

* Fix macro name

* Package name with version number and arch

* Improve package function readability

* Change order of vars

* Testing upload to GDrive

* Add missing return code

* Use positional args and declare error

* Use machine instead of arch and remove build from filename

* Remove redundant build jobs

* Replace massively over-complicated `build_version.py` script

* Move version info to env module

* Use version info script

* Fixed: too many values to unpack

* Chmod version script

* Use shebang

* Don't check return code on Linux

* Fixed function name

* Convert to GitHub specific script

* Env vars must be after configure

* Fixed Windows env var command

* Remove && from deps command so it's not conditional

* Fixed position of set env

* Change order of env script

* Only upload when not draft

* Test

* Tweak config

* Fixed if condition

* Don't package in draft (Windows and Linux)
2024-06-24 09:36:30 +00:00

283 lines
8.8 KiB
Python

import os, subprocess, base64, time, json, shutil, sys
from lib import cmd_utils, env
cmake_env_var = "CMAKE_PREFIX_PATH"
shell_rc = "~/.zshrc"
cert_path = "tmp/codesign.p12"
dist_dir = "dist"
product_name = "Synergy"
settings_file = "res/dist/macos/dmgbuild/settings.py"
app_path = "build/bundle/Synergy.app"
security_path = "/usr/bin/security"
sudo_path = "/usr/bin/sudo"
notarytool_path = "/usr/bin/notarytool"
codesign_path = "/usr/bin/codesign"
xcode_select_path = "/usr/bin/xcode-select"
keychain_path = "/Library/Keychains/System.keychain"
def set_env_var(name, value):
text = f'export {name}="${name}:{value}"'
file = os.path.expanduser(shell_rc)
with open(file, "r") as f:
if text in f.read():
return
print(f"Setting environment variable: {name}={name}")
with open(file, "a") as f:
f.write(f"\n{text}")
print(f"Appended to {shell_rc}: {text}")
def set_cmake_prefix_env_var(cmake_prefix_command):
result = cmd_utils.run(cmake_prefix_command, get_output=True)
cmake_prefix = result.stdout.strip()
set_env_var(cmake_env_var, cmake_prefix)
def package(filename_base):
codesign_id = env.get_env_var("APPLE_CODESIGN_ID")
certificate = env.get_env_var("APPLE_P12_CERTIFICATE")
password = env.get_env_var("APPLE_P12_PASSWORD")
build_bundle()
install_certificate(certificate, password)
assert_certificate_installed(codesign_id)
sign_bundle(codesign_id)
dmg_path = build_dmg(filename_base)
notarize_package(dmg_path)
def build_bundle():
print("Building bundle...")
# cmake build install target should run macdeployqt
cmd_utils.run("cmake --build build --target install")
def sign_bundle(codesign_id):
print(f"Signing bundle {app_path}...")
sys.stdout.flush()
subprocess.run(
[
codesign_path,
"-f",
"--options",
"runtime",
"--deep",
"-s",
codesign_id,
app_path,
],
check=True,
)
def assert_certificate_installed(codesign_id):
installed = cmd_utils.run(
"security find-identity -v -p codesigning", get_output=True
)
if codesign_id not in installed.stdout:
raise RuntimeError("Code signing certificate not installed or has expired")
def build_dmg(filename_base):
env.ensure_module("dmgbuild", "dmgbuild")
import dmgbuild # type: ignore
settings_file_abs = os.path.abspath(settings_file)
app_path_abs = os.path.abspath(app_path)
# cwd for dmgbuild, since setting the dmg filename to a path (include the dist dir) seems to
# make the dmg disappear and never writes to the specified path. the dmgbuild module also
# creates a temporary file in cwd, so it makes sense to change to the dist dir.
print(f"Changing directory to: {os.path.abspath(dist_dir)}")
cwd = os.getcwd()
os.makedirs(dist_dir, exist_ok=True)
os.chdir(dist_dir)
try:
dmg_filename = f"{filename_base}.dmg"
dmg_path = os.path.join(dist_dir, dmg_filename)
print(f"Building package {dmg_path}...")
dmgbuild.build_dmg(
dmg_filename,
product_name,
settings_file=settings_file_abs,
defines={
"app": app_path_abs,
},
)
finally:
print(f"Changing directory back to: {cwd}")
os.chdir(cwd)
return dmg_path
def install_certificate(cert_base64, cert_password):
if not cert_base64:
raise ValueError("Certificate base 64 not provided")
if not cert_password:
raise ValueError("Certificate password not provided")
print(f"Decoding certificate to: {cert_path}")
cert_bytes = base64.b64decode(cert_base64)
os.makedirs(os.path.dirname(cert_path), exist_ok=True)
with open(cert_path, "wb") as cert_file:
cert_file.write(cert_bytes)
print(f"Installing certificate: {cert_path}")
sys.stdout.flush()
try:
# warning: contains private key password, never print this command
subprocess.run(
[
sudo_path,
security_path,
"import",
cert_path,
"-k",
keychain_path,
"-P",
cert_password,
"-T",
codesign_path,
"-T",
security_path,
],
check=True,
)
except subprocess.CalledProcessError as e:
# important: suppress the original args with `from None` to avoid leaking the password
raise subprocess.CalledProcessError(e.returncode, security_path) from None
except Exception as e:
# important: suppress the original args with `from None` to avoid leaking the password
raise RuntimeError(f"Command failed: {security_path}") from None
finally:
# not strictly necessary for ci, but when run on a dev machine, it reduces the risk
# that private keys are left on the filesystem
print(f"Removing temporary certificate file: {cert_path}")
os.remove(cert_path)
def notarize_package(dmg_path):
print(f"Notarizing package {dmg_path}...")
notary_tool = NotaryTool()
notary_tool.store_credentials(
env.get_env_var("APPLE_NOTARY_USER"),
env.get_env_var("APPLE_NOTARY_PASSWORD"),
env.get_env_var("APPLE_TEAM_ID"),
)
notary_tool.submit_and_wait(dmg_path)
def get_xcode_path():
result = cmd_utils.run([xcode_select_path, "-p"], get_output=True, shell=False)
return result.stdout.strip()
class NotaryTool:
"""
Provides a wrapper around the notarytool command line tool.
"""
def __init__(self):
self.xcode_path = get_xcode_path()
def get_path(self):
return f"{self.xcode_path}{notarytool_path}"
def store_credentials(self, user, password, team_id):
print("Storing credentials for notary tool...")
sys.stdout.flush()
notarytool_path = self.get_path()
try:
# warning: contains password, never print this command
subprocess.run(
[
notarytool_path,
"store-credentials",
"notarytool-password",
"--apple-id",
user,
"--team-id",
team_id,
"--password",
password,
],
check=True,
)
except subprocess.CalledProcessError as e:
# important: suppress the original args with `from None` to avoid leaking the password
raise subprocess.CalledProcessError(e.returncode, notarytool_path) from None
except Exception as e:
# important: suppress the original args with `from None` to avoid leaking the password
raise RuntimeError(f"Command failed: {notarytool_path}") from None
def submit_and_wait(self, dmg_filename):
print("Submitting notarization request...")
submit_result = self.run_submit_command(dmg_filename)
request_id = submit_result["id"]
print(f"Notary submitted, waiting for request: {request_id}")
start = time.time()
wait_result = self.run_wait_command(request_id)
status = wait_result["status"]
time_taken = time.time() - start
print(f"Notary complete in {time_taken:.2f}s, status: {status}")
if status == "Accepted":
print("Notarization successful.")
elif status == "Invalid" or status == "Rejected":
raise ValueError(f"Notarization failed, status: {status}")
else:
raise ValueError(f"Unknown status: {status}")
def run_submit_command(self, dmg_filename):
if not os.path.exists(dmg_filename):
raise FileNotFoundError(f"File not found: {dmg_filename}")
result = cmd_utils.run(
[
self.get_path(),
"submit",
dmg_filename,
"--keychain-profile",
"notarytool-password",
"--output-format",
"json",
],
get_output=True,
shell=False,
)
if result.stderr:
return json.loads(result.stderr)
else:
return json.loads(result.stdout)
def run_wait_command(self, request_id):
result = cmd_utils.run(
[
self.get_path(),
"wait",
request_id,
"--keychain-profile",
"notarytool-password",
"--output-format",
"json",
],
get_output=True,
shell=False,
)
if result.stderr:
return json.loads(result.stderr)
else:
return json.loads(result.stdout)