├── .gitignore ├── LICENSE ├── Pipfile ├── README.md ├── Signing_and_Notarizing_HOWTO.md ├── codesign.tgz ├── entitlements.plist ├── entitlements_sample.plist ├── package.sh ├── pycodesign.ipynb ├── pycodesign.py └── pycodesign.tgz /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Aaron Ciuffo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | ipykernel = "*" 10 | pyinstaller = "*" 11 | 12 | [requires] 13 | python_version = "3.8" 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # codesign 2 | Python3 script for signing, packaging, notarizing and stapling Apple command line binaries using `notarytool`. This script only requires Python3 and uses only standard libraries. 3 | 4 | This script is specifically targeted at codesigning, notarizing, creating `.pkg` files and stapling the notarization onto **commandline tools** written and compiled outside of Apple Xcode. This was created specifically for notarizing and signing python tools created with PyInstaller. 5 | 6 | As of MacOS Catalina, all distributed binaries must be signed and notarized using an apple developer account. This account costs $99 per year. *Thieves*. 7 | 8 | Apple's documentation for this process is ***ABSOLUTELY*** terrible. For a guide to doing this manually see [Signing_and_Notarizing_HOWTO](https://github.com/txoof/codesign/blob/main/Signing_and_Notarizing_HOWTO.md) 9 | 10 | ## NEW in v0.3 11 | As of v0.3, this script uses `notarytool` instead of `altool`. `altool` is being deprecated by Apple and will no longer work after November 2023. 12 | 13 | If you are updating from a previous version of `pycodesign`, you will need to create a new keychain profile and update your .ini file. 14 | 15 | ## Requirements 16 | See [this guide](https://github.com/txoof/codesign/blob/main/Signing_and_Notarizing_HOWTO.md) for help in obtaining these requirements. 17 | * Paid apple developer's account 18 | * Developer ID Application certificate 19 | * Developer ID Installer certificate 20 | 21 | ## Quick Start 22 | 1) Download [pycodesign](https://github.com/txoof/codesign/raw/main/pycodesign.tgz) 23 | 2) Unpack and place somehwere in your `$PATH` 24 | 3) Create a keychain profile for notarization using `xcrun notarytool store-credentials YOUR_PROFILE_NAME --apple-id YOUR_APPLE_ID --team-id YOUR_TEAM_ID` 25 | * You will be prompted for your app-specific password. 26 | * For more information, see [this article](https://developer.apple.com/documentation/technotes/tn3147-migrating-to-the-latest-notarization-tool#Save-credentials-in-the-keychain). 27 | 4) Enter directory containing the binaries you wish to sign 28 | 5) run: `pycodesign.py -N` to create a template configuration file 29 | 6) edit the configuration file (see [below](#configFile) for more details 30 | 7) run `pycodesign.py yourconfig.ini` to begin the signing and notarization process 31 | 8) Enter your username and password as needed to unlock your keychain 32 | 9) Once the package is submitted to Apple, `pycodesign` will wait to see if the process is complete. 33 | * Check your email or manually check the notarization status using `xcrun notarytool history --keychain_profile YOUR_PROFILE_NAME` 34 | 10) rejoyce in your signed .pkg file 35 | 36 | ## Manual 37 | Basic Usage: 38 | `$ codesign.py my_config.ini` 39 | 40 | ``` 41 | usage: pycodesign.py [-h] [-v] [-V] [-N] [-s] [-p] [-n] [-t] [-T ] 42 | [-C ] 43 | [] 44 | 45 | PyCodeSign -- Code Signing and Notarization Assistant 46 | 47 | positional arguments: 48 | 49 | configuration file to use when codesigning 50 | 51 | optional arguments: 52 | -h, --help show this help message and exit 53 | -v, --verbose 54 | -V, --version 55 | -N, --new create a new sample configuration with name 56 | "pycodesign.ini" in current directory 57 | -s, --sign sign the executables, but take no further action 58 | (can be combined with -p, -n, -t) 59 | -p, --package package the executables, but take no further action 60 | (can be combined with -s, -n, -t) 61 | -n, --notarize notarize the package, but take no further action 62 | (can be combined with -s, -p, -t) 63 | -t, --staple stape the notarization to the the package, but take 64 | no further action (can be combined with -s, -p, -n) 65 | -O , --pkg_version 66 | overide the version number in the .ini file and use 67 | supplied version number. 68 | ``` 69 | 70 | ## Codesign Configuration File Structure 71 | 72 | For help creating certificates and app-specific passwords see: [Signing_and_Notarizing_HOWTO](https://github.com/txoof/codesign/blob/main/Signing_and_Notarizing_HOWTO.md) 73 | 74 | Use `security find-identity -p basic -v` to view Certificate strings 75 | 76 | Use `curl -LJO https://raw.githubusercontent.com/txoof/codesign/main/entitlements.plist` to quickly download the a sample `entitlements.plist` 77 | ``` 78 | # All [sections] and values are required unless otherwise noted 79 | # whitespace and comments are ignored 80 | 81 | # identification details 82 | [identification] 83 | # unique substring from the Developer ID Application certificate 84 | # such as the HASH or the short team has 85 | application_id = Unique Substring of Developer ID Application Cert 86 | # unique substring from the Developer ID Installer certificate 87 | # such as the HASH or the short team has 88 | installer_id = Unique Substring of Developer ID Installer Cert 89 | # Keychain profile with credentials for app notarization 90 | keychain-profile = Name-of-stored-keychain-profile 91 | 92 | [package_details] 93 | # name of finished package such as "pdfsplitter" or "whizbangtool" 94 | package_name = nameofpackage 95 | # unique bundle identifier -- this is typically in reverse DNS 96 | # format such as com.yoursite.pdfsplitter or com.yoursite.whizbangtool 97 | bundle_id = com.developer.packagename 98 | # paths to files to include in the package specified as comma separated list 99 | file_list = include_file1, include_file2 100 | # path where the Apple .pkg installer will install the tools 101 | # such as /Applications or /usr/local/bin 102 | installation_path = /Applications/ 103 | # entitlements XML -- binaries with embedded libraries such as those use 'None' to skip 104 | # produced by PyInstlaler require a special entitlements.plist 105 | # see the a sample here https://github.com/txoof/codesign/blob/main/entitlements_sample.plist 106 | entitlements = None 107 | # your version number 108 | version = 0.0.0 109 | ``` 110 | -------------------------------------------------------------------------------- /Signing_and_Notarizing_HOWTO.md: -------------------------------------------------------------------------------- 1 | # How to Sign and Notarize a Command Line Tool Manually 2 | 3 | Apple requires that all distributed binaries are signed and notarized using a paid Apple Developer account. This can be done using commandline tools for binaries created with tools such as PyInstaller, or compiled using gcc. 4 | 5 | ## Setup 6 | 7 | If you already have a developer account with `Developer ID Application` and `Developer ID Installer` certificates configured in XCode, skip this step 8 | 9 | * Create a developer account with Apple 10 | * and shell out $99 for a developer account. *Theives* 11 | * Download and install X-Code from the Apple App Store 12 | * https://developer.apple.com/download/all/?q=Xcode (this requires a sign in, or can be downloaded from the App Store) 13 | * Open and run X-Code app and install whatever extras it requires 14 | * Open the preferences pane (cmd+,) and choose *Accounts*( 15 | * click the `+` in the lower left corner 16 | * choose `Apple ID` 17 | * enter your apple ID and password 18 | * Previously created keys can be downloaded and installed from 19 | * Select the developer account you wish to use 20 | * Choose *Manage Certificates...* 21 | * Click the `+` in the lower left corner and choose *Developer ID Application* 22 | * Click the `+` in the lower left corner and choose *Developer ID Installer* 23 | 24 | ## Create an App-Specific password for altool to use 25 | 26 | * [Instructions from Apple](https://support.apple.com/en-us/HT204397) 27 | * Open `KeyChain Access` 28 | * Create a "New Password Item" 29 | * Keychain Item Name: Developer-altool 30 | * Account Name: your developer account email 31 | * Password: the application-specific password you just created 32 | 33 | ## Create an executable binary with Pyinstaller or other tool 34 | 35 | **NB!** Additional args such as `--add-data` may be needed to build a functional binary 36 | 37 | * Create a onefile binary 38 | * `pyinstaller --onefile myapp.py` 39 | 40 | ## Sign the executable 41 | 42 | * Add the entitements.plist to the directory (see below) 43 | * List the available keys and locate a Developer ID Application certificate: 44 | * `security find-identity -p basic -v` 45 | 46 | ``` 47 | 1) ABC123 "Apple Development: aaronciuffonl@gmail.com ()" 48 | 2) XYZ234 "Developer ID Installer: Aaron Ciuffo ()" 49 | 3) QRS333 "Developer ID Application: Aaron Ciuffo ()" 50 | 4) LMN343 "Developer ID Application: Aaron Ciuffo ()" 51 | 5) ZPQ234 "Apple Development: aaron.ciuffo@gmail.com ()" 52 | 6) ASD234 "Developer ID Application: Aaron Ciuffo ()" 53 | 7) 01010A "Developer ID Application: Aaron Ciuffo ()" 54 | 7 valid identities found 55 | ``` 56 | 57 | * `codesign --deep --force --options=runtime --entitlements ./entitlements.plist --sign "HASH_OF_DEVELOPER_ID APPLICATION" --timestamp ./dist/foo.app` 58 | 59 | ## Package as a pkg for installation 60 | 61 | * Create a temp directory to build the package: 62 | * `mkdir /tmp/myapp` 63 | * Use ditto to build the pkg installer structure 64 | * `ditto /path/to/myapp /tmp/myapp/path/to/install/location` 65 | * to install application "WhizBang" into `/Applications/` on the target use: `ditto ~/src/whiz_bang/dist/whizBang /tmp/whiz_bang/Applications/` 66 | * repeat for all files that should be packaged 67 | * build the package 68 | 69 | * `productbuild --identifier "com.your.pkgname.pkg" --sign "HASH_OF_INSTALLER_ID" --timestamp --root /tmp/myapp / myapp.pkg` 70 | * **NB!** the format for the `--root` option is as follows: `--root` `` `` `` 71 | 72 | ## Notarize 73 | 74 | * `xcrun altool --notarize-app --primary-bundle-id "com.foobar.fooapp" --username="developer@foo.com" --password "@keychain:Developer-altool" --file ./myapp.pkg` 75 | * Check email for successful notarization 76 | * Alternatively check status using: 77 | * `xcrun altool --notarization-history 0 -u "developer@***" -p "@keychain:Developer-altool"` 78 | * If notarization fails use the following to review a detailed log: 79 | 80 | ``` 81 | xcrun altool --notarization-info "Your-Request-UUID" \ 82 | --username "username@example.com" \ 83 | --password "@keychain:Developer-altool" 84 | ``` 85 | 86 | ## Staple notarization to pkg 87 | 88 | * add the notariztaion to the pkg 89 | * `xcrun stapler staple ghostscript64.pkg` 90 | 91 | # Useful Resources 92 | 93 | * [Norarize a Commandline utility](https://scriptingosx.com/2019/09/notarize-a-command-line-tool/) 94 | * This blog details setting up: 95 | * a developer profile & certificates 96 | * one time passwords 97 | * creating keychain entries to allow the `-p "@keychain:Key"` switch to work 98 | * Signing and Notarizing 99 | * Satpling 100 | * [Adding an `entitlements.plist` to the signing process](https://github.com/pyinstaller/pyinstaller/issues/4629#issuecomment-574375331) 101 | * ensure that embedded python libraries can be access appropriately 102 | * [Signing and Notarizing tools compiled outside of XCode](https://developer.apple.com/forums/thread/130379) 103 | * covers: 104 | * signing 105 | * packaging 106 | * notarizing 107 | * stapling 108 | 109 | # Alternative workflows that may have issues 110 | 111 | ## Create a bundled app with pyinstaller 112 | 113 | * Only ".app" bundles appear to work using this procedure 114 | * `pyinstaller --windowed --onefile foo.py` 115 | * edit the spec file `app = BUNDLE` section to include a bundle_identifier 116 | 117 | ``` 118 | app = BUNDLE(exe, 119 | name='helloworld.app', 120 | icon=None, 121 | bundle_identifier='com.txoof.helloworld' 122 | ) 123 | ``` 124 | 125 | * **NOTE!** Appbundles will not execute properly -- they must be run by execuing the `bundle.app/Contents/MacOS/myapp` 126 | 127 | ## package as a dmg 128 | 129 | **NB! This may not work for single file executables -- use the PKG method above** 130 | 131 | * Create a `.dmg`: 132 | * clean any uneeded files out of `./dist`; only the .app should remain 133 | * `hdiutil create ./myapp.dmg -ov -volname "MyApp" -fs HFS+ -srcfolder "./dist"` 134 | * Shrink and make read-only: 135 | * `$hdiutil convert ./myapp.dmg -format UDZO -o myapp.dmg` 136 | -------------------------------------------------------------------------------- /codesign.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/txoof/codesign/72f244cf352364b34ae46af5a7cfe7cd30425cc0/codesign.tgz -------------------------------------------------------------------------------- /entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | com.apple.security.cs.allow-jit 7 | 8 | com.apple.security.cs.allow-unsigned-executable-memory 9 | 10 | com.apple.security.cs.disable-library-validation 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /entitlements_sample.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | com.apple.security.cs.allow-jit 7 | 8 | com.apple.security.cs.allow-unsigned-executable-memory 9 | 10 | com.apple.security.cs.disable-library-validation 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | script_name="pycodesign" 3 | 4 | tar cvzf $script_name.tgz ./$script_name.py 5 | 6 | git commit -m "refresh tgz" $script_name.tgz 7 | git push 8 | -------------------------------------------------------------------------------- /pycodesign.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 8, 6 | "metadata": {}, 7 | "outputs": [ 8 | { 9 | "name": "stdout", 10 | "output_type": "stream", 11 | "text": [ 12 | "[NbConvertApp] Converting notebook pycodesign.ipynb to python\n", 13 | "[NbConvertApp] Writing 20380 bytes to pycodesign.py\n" 14 | ] 15 | } 16 | ], 17 | "source": [ 18 | "!jupyter-nbconvert --to python --template python_clean pycodesign.ipynb" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": null, 24 | "metadata": {}, 25 | "outputs": [], 26 | "source": [ 27 | "version = '0.3'\n", 28 | "\n", 29 | "import logging\n", 30 | "import configparser\n", 31 | "import argparse\n", 32 | "# from distutils import util\n", 33 | "import subprocess\n", 34 | "import shlex\n", 35 | "import tempfile\n", 36 | "from pathlib import Path\n", 37 | "from time import sleep\n", 38 | "import sys\n", 39 | "from shutil import rmtree" 40 | ] 41 | }, 42 | { 43 | "cell_type": "code", 44 | "execution_count": null, 45 | "metadata": {}, 46 | "outputs": [], 47 | "source": [ 48 | "def strtobool (val):\n", 49 | " \"\"\"Convert a string representation of truth to true (1) or false (0).\n", 50 | " True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values\n", 51 | " are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if\n", 52 | " 'val' is anything else. \n", 53 | " \n", 54 | " courtesy of \n", 55 | " https://github.com/python/cpython/blob/main/Lib/distutils/util.py\n", 56 | " \"\"\"\n", 57 | " val = val.lower()\n", 58 | " if val in ('y', 'yes', 't', 'true', 'on', '1'):\n", 59 | " return 1\n", 60 | " elif val in ('n', 'no', 'f', 'false', 'off', '0'):\n", 61 | " return 0\n", 62 | " else:\n", 63 | " raise ValueError(\"invalid truth value %r\" % (val,))" 64 | ] 65 | }, 66 | { 67 | "cell_type": "code", 68 | "execution_count": null, 69 | "metadata": {}, 70 | "outputs": [], 71 | "source": [ 72 | "def get_config(args, default_config=None, filename='pycodesign.ini'):\n", 73 | " file = args.config\n", 74 | " config = configparser.ConfigParser()\n", 75 | " \n", 76 | " if file:\n", 77 | " print (f'using configuration file: {file}')\n", 78 | " config.read(file)\n", 79 | " elif default_config and args.new_config:\n", 80 | " config.read_dict(default_config)\n", 81 | " print(f'writing default config file: {filename}')\n", 82 | " try:\n", 83 | " with open(filename, 'w') as blank_config:\n", 84 | " config.write(blank_config)\n", 85 | " except OSError as e:\n", 86 | " print(f'could not create {filename} due to error: {e}')\n", 87 | " return {}\n", 88 | " else:\n", 89 | " return {}\n", 90 | "\n", 91 | " return {s:dict(config.items(s)) for s in config.sections()}" 92 | ] 93 | }, 94 | { 95 | "cell_type": "code", 96 | "execution_count": null, 97 | "metadata": {}, 98 | "outputs": [], 99 | "source": [ 100 | "def get_args():\n", 101 | "\n", 102 | " saved =[]\n", 103 | " for k in sys.argv:\n", 104 | " saved.append(k)\n", 105 | "\n", 106 | " if '-f' in saved:\n", 107 | " logging.info('working in interactive jupyter environ')\n", 108 | " try:\n", 109 | " sys.argv = sys.argv[:sys.argv.index('-f')]\n", 110 | " except ValueError as e:\n", 111 | " pass\n", 112 | " \n", 113 | " \n", 114 | " \n", 115 | " parser = argparse.ArgumentParser(description='PyCodeSign -- Code Signing and Notarization Assistant')\n", 116 | " \n", 117 | " parser.add_argument('-v', '--verbose', action='count', default=1)\n", 118 | " \n", 119 | " parser.add_argument('-V', '--version', dest='version',\n", 120 | " action='store_true', default=False)\n", 121 | " \n", 122 | " parser.add_argument('-N', '--new', dest='new_config', \n", 123 | " action='store_true', default=False,\n", 124 | " help='create a new sample configuration with name \"pycodesign.ini\" in current directory')\n", 125 | " \n", 126 | " parser.add_argument('config', nargs='?', type=str, default=None,\n", 127 | " help='configuration file to use when codesigning',\n", 128 | " metavar='')\n", 129 | " \n", 130 | " parser.add_argument('-s', '--sign', dest='sign_only',\n", 131 | " action='store_true', default=None,\n", 132 | " help='sign the executables, but take no further action (can be combined with -p, -n, -t)')\n", 133 | " \n", 134 | " parser.add_argument('-p', '--package', dest='package_only',\n", 135 | " action='store_true', default=None,\n", 136 | " help='package the executables, but take no further action (can be combined with -s, -n, -t)')\n", 137 | " \n", 138 | " parser.add_argument('-P', '--package_debug', dest='package_debug', \n", 139 | " action='store_true', default=None,\n", 140 | " help='package but leave temporary files in place for debugging')\n", 141 | " \n", 142 | " parser.add_argument('-n', '--notarize', dest='notarize_only',\n", 143 | " action='store_true', default=None,\n", 144 | " help='notarize the package, but take no further action (can be combined with -s, -p, -t)')\n", 145 | "\n", 146 | " parser.add_argument('-t', '--staple', dest='staple_only',\n", 147 | " action='store_true', default=None,\n", 148 | " help='stape the notarization to the the package, but take no further action (can be combined with -s, -p, -n)')\n", 149 | " \n", 150 | " #parser.add_argument('-T', '--notarize_timer', type=int, default=60,\n", 151 | " # metavar=\"\",\n", 152 | " # help='base time in seconds to wait between checking notarization status with apple (default 60)')\n", 153 | " \n", 154 | " #parser.add_argument('-C', '--num_checks', type=int, default=5, \n", 155 | " # metavar=\"\",\n", 156 | " # help='number of times to check notarization status with apple (default 5) -- each check doubles notarize_timer')\n", 157 | "\n", 158 | " parser.add_argument('-O', '--pkg_version', type=str, \n", 159 | " default=None, \n", 160 | " metavar=\"\",\n", 161 | " help='overide the version number in the .ini file and use supplied version number.') \n", 162 | " \n", 163 | "# known_args, unknown_args = parser.parse_known_args()\n", 164 | " args = parser.parse_args()\n", 165 | "# return(known_args, unknown_args)\n", 166 | " return args" 167 | ] 168 | }, 169 | { 170 | "cell_type": "code", 171 | "execution_count": null, 172 | "metadata": {}, 173 | "outputs": [], 174 | "source": [ 175 | "def validate_config(config, expected_keys):\n", 176 | " missing = {}\n", 177 | " for section, keys in expected_keys.items():\n", 178 | " if not section in config.keys():\n", 179 | " missing[section] = expected_keys[section]\n", 180 | " continue\n", 181 | " for key in keys:\n", 182 | " if not key in config[section].keys():\n", 183 | " if not section in missing:\n", 184 | " missing[section] = {}\n", 185 | " missing[section][key] = keys[key]\n", 186 | " \n", 187 | " if missing:\n", 188 | " print('Config file is missing values:')\n", 189 | " for section, values in missing.items():\n", 190 | " print(f'[{section}]')\n", 191 | " for k, v in values.items():\n", 192 | " print(f'\\t{k}: {v}')\n", 193 | " \n", 194 | " return missing" 195 | ] 196 | }, 197 | { 198 | "cell_type": "code", 199 | "execution_count": null, 200 | "metadata": {}, 201 | "outputs": [], 202 | "source": [ 203 | "def run_command(command_list):\n", 204 | " cmd = subprocess.Popen(shlex.split(' '.join(command_list)), \n", 205 | " stdout=subprocess.PIPE,\n", 206 | " stderr=subprocess.PIPE)\n", 207 | " stderr, stdout = cmd.communicate()\n", 208 | " return cmd.returncode, stderr, stdout" 209 | ] 210 | }, 211 | { 212 | "cell_type": "code", 213 | "execution_count": null, 214 | "metadata": {}, 215 | "outputs": [], 216 | "source": [ 217 | "def sign(config):\n", 218 | " \n", 219 | " try:\n", 220 | " entitlements = strtobool(config['package_details']['entitlements'])\n", 221 | " except (AttributeError, ValueError):\n", 222 | " entitlements = config['package_details']['entitlements']\n", 223 | " \n", 224 | " if (not entitlements) or (entitlements == 'None'):\n", 225 | " entitlements = None\n", 226 | " \n", 227 | " config['package_details']['entitlements'] = entitlements\n", 228 | "\n", 229 | " args = {\n", 230 | " 'command': 'codesign',\n", 231 | " 'args': '--deep --force --timestamp --options=runtime',\n", 232 | " 'entitlements': f'--entitlements {config[\"package_details\"][\"entitlements\"]}' if config[\"package_details\"][\"entitlements\"] else None,\n", 233 | " 'signature': f'--sign {config[\"identification\"][\"application_id\"]}',\n", 234 | " 'files': ' '.join(config['package_details']['file_list'])\n", 235 | " }\n", 236 | " \n", 237 | " final_list = [i if i is not None else '' for k, i in args.items()]\n", 238 | " logging.debug('running command:')\n", 239 | " logging.debug(' '.join(final_list))\n", 240 | "\n", 241 | " print(f'signing files: {args[\"files\"]}')\n", 242 | " \n", 243 | " return_code, stdout, stderr = run_command(final_list)\n", 244 | " logging.debug(f'return code: {return_code}')\n", 245 | " logging.debug(f'stdout: {stdout}')\n", 246 | " logging.debug(f'stderr: {stderr}')\n", 247 | " \n", 248 | " return return_code, stdout, stderr\n", 249 | " " 250 | ] 251 | }, 252 | { 253 | "cell_type": "code", 254 | "execution_count": null, 255 | "metadata": {}, 256 | "outputs": [], 257 | "source": [ 258 | "def package(config, package_debug=False):\n", 259 | " pkg_temp = Path(tempfile.mkdtemp()).resolve()\n", 260 | " \n", 261 | " install_path = Path(config['package_details']['installation_path']).resolve()\n", 262 | " \n", 263 | " temp_path = Path(f'{pkg_temp}/{install_path}')\n", 264 | " \n", 265 | "# return pkg_temp, install_path\n", 266 | " \n", 267 | " logging.debug(f'pkg_temp: {pkg_temp}')\n", 268 | " logging.debug(f'install_path: {install_path}')\n", 269 | " logging.debug(f'temp_path: {temp_path}')\n", 270 | " \n", 271 | " for file in config['package_details']['file_list']:\n", 272 | " my_file = Path(file).resolve()\n", 273 | " file_name = my_file.name\n", 274 | " \n", 275 | " command = f'ditto {my_file} {temp_path/file_name}'\n", 276 | " logging.debug(f'running command: {command}')\n", 277 | " r, o, e = run_command(shlex.split(command))\n", 278 | " if not process_return(r, o, e):\n", 279 | " logging.warning('could not ditto file into temp path')\n", 280 | " if not package_debug:\n", 281 | " rmtree(pkg_temp)\n", 282 | " return r, o, e\n", 283 | " \n", 284 | " args = {\n", 285 | " 'command': 'productbuild',\n", 286 | " 'identifier': f'--identifier {config[\"package_details\"][\"bundle_id\"]}.pkg',\n", 287 | " 'signature': f'--sign {config[\"identification\"][\"installer_id\"]}',\n", 288 | " 'args': '--timestamp',\n", 289 | " 'version': f'--version {config[\"package_details\"][\"version\"]}',\n", 290 | " 'root': f'--root {pkg_temp} / ./{config[\"package_details\"][\"package_name\"]}.pkg'\n", 291 | " \n", 292 | " }\n", 293 | " \n", 294 | " print(f'packaging {config[\"package_details\"][\"package_name\"]}.pkg')\n", 295 | " final_list = [i if i is not None else '' for k, i in args.items()]\n", 296 | " \n", 297 | " logging.debug('running command:')\n", 298 | " logging.debug(' '.join(final_list)) \n", 299 | " \n", 300 | " r, o, e = run_command(final_list)\n", 301 | " \n", 302 | " \n", 303 | "# logging.debug(f'return code: {return_code}')\n", 304 | "# logging.debug(f'stdout: {stdout}')\n", 305 | "# logging.debug(f'stderr: {stderr}')\n", 306 | " \n", 307 | " if not package_debug:\n", 308 | " rmtree(pkg_temp, ignore_errors=True)\n", 309 | " else:\n", 310 | " print(f'Package debugging active:')\n", 311 | " print(f'Temp files: {pkg_temp}')\n", 312 | " return r, o, e \n", 313 | " \n", 314 | " " 315 | ] 316 | }, 317 | { 318 | "cell_type": "code", 319 | "execution_count": null, 320 | "metadata": {}, 321 | "outputs": [], 322 | "source": [ 323 | "# def package(config, package_debug=False):\n", 324 | "# pkg_temp = tempfile.mkdtemp()\n", 325 | "# pkg_temp_path = Path(pkg_temp).resolve()\n", 326 | "# install_path = Path(config['package_details']['installation_path']).resolve()\n", 327 | "# logging.debug(f'using pkg_temp_path: {pkg_temp_path}')\n", 328 | "# logging.debug(f'install_path: {install_path}')\n", 329 | "# for file in config['package_details']['file_list']:\n", 330 | "# my_file = Path(file).resolve()\n", 331 | "# file_name = my_file.name\n", 332 | "# logging.debug(f'copying file: {file}')\n", 333 | "# command = f'ditto {file} {pkg_temp_path/install_path/file_name}'\n", 334 | "# logging.debug(f'run command:\\n {command}')\n", 335 | "# return_code, stderr, stdout = run_command(shlex.split(command))\n", 336 | "# if return_code > 0:\n", 337 | "# pkg_temp.cleanup()\n", 338 | "# return return_code, stderr, stdout\n", 339 | " \n", 340 | "# args = {\n", 341 | "# 'command': 'productbuild',\n", 342 | "# 'identifier': f'--identifier {config[\"package_details\"][\"bundle_id\"]}.pkg',\n", 343 | "# 'signature': f'--sign {config[\"identification\"][\"installer_id\"]}',\n", 344 | "# 'args': '--timestamp',\n", 345 | "# 'version': f'--version {config[\"package_details\"][\"version\"]}',\n", 346 | "# 'root': f'--root {pkg_temp_path} / ./{config[\"package_details\"][\"package_name\"]}.pkg'\n", 347 | " \n", 348 | "# }\n", 349 | " \n", 350 | "# print(f'packaging {config[\"package_details\"][\"package_name\"]}.pkg')\n", 351 | "# final_list = [i if i is not None else '' for k, i in args.items()]\n", 352 | " \n", 353 | "# logging.debug('running command:')\n", 354 | "# logging.debug(' '.join(final_list)) \n", 355 | " \n", 356 | "# return_code, stdout, stderr = run_command(final_list)\n", 357 | " \n", 358 | "# logging.debug(f'return code: {return_code}')\n", 359 | "# logging.debug(f'stdout: {stdout}')\n", 360 | "# logging.debug(f'stderr: {stderr}')\n", 361 | " \n", 362 | "# if not package_debug:\n", 363 | "# rmtree(pkg_temp_path, ignore_errors=True)\n", 364 | "# else:\n", 365 | "# print(f'Package debugging active:')\n", 366 | "# print(f'Temp files: {pkg_temp_path}')\n", 367 | "# return return_code, stdout, stderr" 368 | ] 369 | }, 370 | { 371 | "cell_type": "code", 372 | "execution_count": 4, 373 | "metadata": {}, 374 | "outputs": [], 375 | "source": [ 376 | "def notarize(config):\n", 377 | " notarize_args = {\n", 378 | " 'command': 'xcrun notarytool',\n", 379 | " 'args': 'submit --wait',\n", 380 | " #'bundle_id': f'--primary-bundle-id {config[\"package_details\"][\"bundle_id\"]}',\n", 381 | " #'username': f'--username={config[\"identification\"][\"apple_id\"]}',\n", 382 | " #'password': f'--password {config[\"identification\"][\"password\"]}',\n", 383 | " 'keychain-profile': f'--keychain-profile {config[\"identification\"][\"keychain-profile\"]}',\n", 384 | " 'file': f'{config[\"package_details\"][\"package_name\"]}.pkg'\n", 385 | " }\n", 386 | " \n", 387 | " \n", 388 | " \n", 389 | " final_list = [i for k, i, in notarize_args.items()]\n", 390 | " logging.debug('running command:')\n", 391 | " logging.debug(' '.join(final_list)) \n", 392 | " \n", 393 | " return_code, stdout, stderr = run_command(final_list)\n", 394 | " \n", 395 | " logging.debug(f'return code: {return_code}')\n", 396 | " logging.debug(f'stdout: {stdout}')\n", 397 | " logging.debug(f'stderr: {stderr}') \n", 398 | " \n", 399 | " return return_code, stdout, stderr" 400 | ] 401 | }, 402 | { 403 | "cell_type": "code", 404 | "execution_count": null, 405 | "metadata": {}, 406 | "outputs": [], 407 | "source": [ 408 | "def check_notarization(stdout, config):\n", 409 | " notarize_max_check = config['main']['notrarize_max_check']\n", 410 | " notarize_check = 0\n", 411 | " notarized = False\n", 412 | " \n", 413 | " \n", 414 | " uuids = []\n", 415 | " for line in str(stdout, 'utf-8').splitlines():\n", 416 | " if 'requestuuid' in line.lower():\n", 417 | " my_id = line.split('=')\n", 418 | " uuids.append(my_id[1].strip()) \n", 419 | " logging.debug('uuids found: ')\n", 420 | " logging.debug(uuids)\n", 421 | "\n", 422 | " check_args = {\n", 423 | " 'command': 'xcrun altool',\n", 424 | " 'info': f'--notarization-info {uuids[0]}',\n", 425 | " 'username': f'--username {config[\"identification\"][\"apple_id\"]}',\n", 426 | " 'password': f'--password {config[\"identification\"][\"password\"]}'\n", 427 | " }\n", 428 | " final_list = [i for k, i in check_args.items()] \n", 429 | " \n", 430 | " \n", 431 | " while not notarized:\n", 432 | " status = {}\n", 433 | " success = None\n", 434 | " print('checking notarization status')\n", 435 | " notarize_check += 1\n", 436 | " print(f'check: {notarize_check} of {notarize_max_check}')\n", 437 | "\n", 438 | " logging.debug('running command:')\n", 439 | " logging.debug(' '.join(final_list)) \n", 440 | "\n", 441 | " return_code, stdout, stderr = run_command(final_list)\n", 442 | "\n", 443 | " logging.debug(f'return code: {return_code}')\n", 444 | " logging.debug(f'stdout: {stdout}')\n", 445 | " logging.debug(f'stderr: {stderr}')\n", 446 | "\n", 447 | " if stdout:\n", 448 | " lines = str(stdout, 'utf-8').splitlines()\n", 449 | "\n", 450 | " for l in lines:\n", 451 | " if 'status' in l.lower():\n", 452 | " vals = l.split(':')\n", 453 | " status[vals[0].strip().lower()] = vals[1].strip()\n", 454 | " \n", 455 | " try:\n", 456 | " if status['status'] == 'success':\n", 457 | " success = True\n", 458 | " if status['status'] == 'invalid':\n", 459 | " success = False\n", 460 | " except KeyError as e:\n", 461 | " logging.debug(f'inconclusive notarization status data returned: {status}')\n", 462 | "\n", 463 | "\n", 464 | " logging.debug('status: ')\n", 465 | " logging.debug(status)\n", 466 | "\n", 467 | " if success is True:\n", 468 | " logging.debug('successfully notarized')\n", 469 | " notarized=True\n", 470 | " elif success is False:\n", 471 | " logging.debug('notarization failed')\n", 472 | " break\n", 473 | " else:\n", 474 | " print(f'notarization not complete: {status}')\n", 475 | " if notarize_check >= notarize_max_check-1:\n", 476 | " print('notarization failed')\n", 477 | " break\n", 478 | " sleep_timer = config['main']['notarize_timer']*notarize_check\n", 479 | " print(f'sleeping for {sleep_timer} seconds')\n", 480 | " logging.debug(f'notarization not complete; sleeping for {sleep_timer}')\n", 481 | " sleep(sleep_timer)\n", 482 | " return notarized" 483 | ] 484 | }, 485 | { 486 | "cell_type": "code", 487 | "execution_count": null, 488 | "metadata": {}, 489 | "outputs": [], 490 | "source": [ 491 | "def staple(config):\n", 492 | " args = {\n", 493 | " 'command': 'xcrun stapler',\n", 494 | " 'args': 'staple',\n", 495 | " 'package': f'{config[\"package_details\"][\"package_name\"]}.pkg'\n", 496 | " }\n", 497 | " \n", 498 | " final_list = [i for k, i in args.items()]\n", 499 | " \n", 500 | " logging.debug('running command:')\n", 501 | " logging.debug(' '.join(final_list)) \n", 502 | "\n", 503 | " \n", 504 | " return_code, stdout, stderr = run_command(final_list)\n", 505 | "\n", 506 | " logging.debug(f'return code: {return_code}')\n", 507 | " logging.debug(f'stdout: {stdout}')\n", 508 | " logging.debug(f'stderr: {stderr}')\n", 509 | "\n", 510 | " \n", 511 | " return return_code, stdout, stderr" 512 | ] 513 | }, 514 | { 515 | "cell_type": "code", 516 | "execution_count": null, 517 | "metadata": {}, 518 | "outputs": [], 519 | "source": [ 520 | "def process_return(return_value, stdout, stderr):\n", 521 | " def byte_print(byte_str):\n", 522 | " if isinstance(byte_str, bytes):\n", 523 | " for line in str(byte_str, 'utf-8').splitlines():\n", 524 | " print(line)\n", 525 | " else:\n", 526 | " print(byte_str)\n", 527 | " \n", 528 | "\n", 529 | " if len(stdout) > 0:\n", 530 | " print('OUTPUT: ')\n", 531 | " byte_print(stdout)\n", 532 | " if len(stderr) > 0:\n", 533 | " print('ERRORS:')\n", 534 | " byte_print(stderr)\n", 535 | " \n", 536 | " if return_value > 0:\n", 537 | " retval = False\n", 538 | " print('failed\\n\\n')\n", 539 | " else:\n", 540 | " retval = True\n", 541 | " print('success\\n\\n')\n", 542 | "\n", 543 | " return retval\n", 544 | " " 545 | ] 546 | }, 547 | { 548 | "cell_type": "code", 549 | "execution_count": null, 550 | "metadata": {}, 551 | "outputs": [], 552 | "source": [ 553 | "## Testing code\n", 554 | "#sys.argv = sys.argv[:1]\n", 555 | "\n", 556 | "# sys.argv.extend(['-O', '9.9.9'])\n", 557 | "# sys.argv.append('insert_files_codesign.ini')\n", 558 | "\n", 559 | "\n", 560 | "# expected_config_keys = {\n", 561 | "# 'identification': {\n", 562 | "# 'application_id': 'Unique Substring of Developer ID Application Cert',\n", 563 | "# 'installer_id': 'Unique Substring of Developer ID Installer Cert',\n", 564 | "# 'apple_id': 'developer@domain.com',\n", 565 | "# 'password': '@keychain:App-Specific-Password-Name-In-Keychain',\n", 566 | "# },\n", 567 | "# 'package_details': {\n", 568 | "# 'package_name': 'nameofpackage',\n", 569 | "# 'bundle_id': 'com.developer.packagename',\n", 570 | "# 'file_list': \"include_file1, include_file2\",\n", 571 | "# 'installation_path': '/Applications/',\n", 572 | "# 'entitlements': 'None',\n", 573 | "# 'version': '0.0.0'\n", 574 | "# }\n", 575 | "# }\n", 576 | "\n", 577 | "# logging.root.setLevel(\"DEBUG\")\n", 578 | "# args = get_args()\n", 579 | "# config = get_config(args=args, default_config=expected_config_keys)\n", 580 | "\n", 581 | "# config.update({'main': {\n", 582 | "# 'notarize_timer': args.notarize_timer,\n", 583 | "# 'notrarize_max_check': args.num_checks,\n", 584 | "# 'new_config': args.new_config}\n", 585 | "# })\n", 586 | "\n", 587 | "# if args.pkg_version:\n", 588 | "# config['package_details']['version'] = args.pkg_version\n", 589 | "\n", 590 | "# validate_config(config, expected_config_keys)" 591 | ] 592 | }, 593 | { 594 | "cell_type": "code", 595 | "execution_count": null, 596 | "metadata": {}, 597 | "outputs": [], 598 | "source": [ 599 | "def main():\n", 600 | " logger = logging.getLogger(__name__)\n", 601 | "\n", 602 | " expected_config_keys = {\n", 603 | " 'identification': {\n", 604 | " 'application_id': 'Unique Substring of Developer ID Application Cert',\n", 605 | " 'installer_id': 'Unique Substring of Developer ID Installer Cert',\n", 606 | " 'keychain-profile': 'Name-of-stored-keychain-profile'\n", 607 | " },\n", 608 | " 'package_details': {\n", 609 | " 'package_name': 'nameofpackage',\n", 610 | " 'bundle_id': 'com.developer.packagename',\n", 611 | " 'file_list': \"include_file1, include_file2\",\n", 612 | " 'installation_path': '/Applications/',\n", 613 | " 'entitlements': 'None',\n", 614 | " 'version': '0.0.0'\n", 615 | " }\n", 616 | " }\n", 617 | " run_all = True\n", 618 | " \n", 619 | "# notarize_timer = 60\n", 620 | "# notrarize_max_check = 5\n", 621 | " halt = False\n", 622 | " \n", 623 | " args = get_args()\n", 624 | "\n", 625 | " verbose = 50 - (args.verbose*10) \n", 626 | " if verbose < 10:\n", 627 | " verbose = 10\n", 628 | " logging.root.setLevel(verbose)\n", 629 | " \n", 630 | " if args.version:\n", 631 | " print(f'{sys.argv[0]} V{version}')\n", 632 | " return\n", 633 | " \n", 634 | " config = get_config(args=args, default_config=expected_config_keys)\n", 635 | " if not config:\n", 636 | " print('no configuration file provided')\n", 637 | " print(f'try:\\n$ {sys.argv[0]} -h')\n", 638 | " return\n", 639 | " \n", 640 | " #config.update({'main': {\n", 641 | " # 'notarize_timer': args.notarize_timer,\n", 642 | " # 'notrarize_max_check': args.num_checks,\n", 643 | " # 'new_config': args.new_config}\n", 644 | " # })\n", 645 | " \n", 646 | " if args.pkg_version:\n", 647 | " config['package_details']['version'] = args.pkg_version\n", 648 | " \n", 649 | " logging.debug('using config:')\n", 650 | " logging.debug(config)\n", 651 | " \n", 652 | " if validate_config(config, expected_config_keys):\n", 653 | " print('exiting')\n", 654 | " return\n", 655 | " \n", 656 | " # split the file list into an actual list\n", 657 | " try:\n", 658 | " file_list = config['package_details']['file_list'].split(',')\n", 659 | " config['package_details']['file_list'] = file_list\n", 660 | " except KeyError:\n", 661 | " pass\n", 662 | " \n", 663 | " \n", 664 | " check_args =[args.notarize_only,\n", 665 | " args.package_only,\n", 666 | " args.sign_only,\n", 667 | " args.staple_only,\n", 668 | " args.package_debug ]\n", 669 | " \n", 670 | " for each in check_args:\n", 671 | " if each:\n", 672 | " run_all = False\n", 673 | " \n", 674 | "# if args.notarize_only or args.package_only or args.sign_only or args.staple_only or args.package_debug:\n", 675 | "# run_all = False\n", 676 | " \n", 677 | " if args.sign_only or run_all:\n", 678 | " print('signing...')\n", 679 | " r, o, e = sign(config)\n", 680 | " process_return(r, o, e)\n", 681 | " if r > 0:\n", 682 | " halt = True\n", 683 | " \n", 684 | " if args.package_only or args.package_debug or run_all and not halt:\n", 685 | " print('packaging...')\n", 686 | " r, o, e = package(config, args.package_debug)\n", 687 | " process_return(r, o, e)\n", 688 | " if r > 0:\n", 689 | " halt = True\n", 690 | " \n", 691 | " if args.notarize_only or run_all and not halt:\n", 692 | " print('notarizing...')\n", 693 | " r, o, e = notarize(config)\n", 694 | " process_return(r, o, e)\n", 695 | " if r == 0:\n", 696 | " print('notaization process at Apple completed')\n", 697 | " else:\n", 698 | " print('notariztion process did not complete or was inconclusive')\n", 699 | " print(f'check manually with: ')\n", 700 | " print(f'xcrun notarytool history --keychain-profile {config[\"identification\"][\"keychain-profile\"]}')\n", 701 | " halt = True\n", 702 | " \n", 703 | " if args.staple_only or run_all and not halt:\n", 704 | " print('stapling...')\n", 705 | " r, o, e = staple(config)\n", 706 | " process_return(r, o, e)\n", 707 | " if r > 0:\n", 708 | " halt = True\n", 709 | "\n", 710 | " \n", 711 | " return config \n", 712 | " " 713 | ] 714 | }, 715 | { 716 | "cell_type": "code", 717 | "execution_count": null, 718 | "metadata": {}, 719 | "outputs": [], 720 | "source": [ 721 | "if __name__ == '__main__':\n", 722 | " c = main()" 723 | ] 724 | }, 725 | { 726 | "cell_type": "code", 727 | "execution_count": null, 728 | "metadata": {}, 729 | "outputs": [], 730 | "source": [] 731 | } 732 | ], 733 | "metadata": { 734 | "kernelspec": { 735 | "display_name": "Python 3 (ipykernel)", 736 | "language": "python", 737 | "name": "python3" 738 | }, 739 | "language_info": { 740 | "codemirror_mode": { 741 | "name": "ipython", 742 | "version": 3 743 | }, 744 | "file_extension": ".py", 745 | "mimetype": "text/x-python", 746 | "name": "python", 747 | "nbconvert_exporter": "python", 748 | "pygments_lexer": "ipython3", 749 | "version": "3.11.4" 750 | } 751 | }, 752 | "nbformat": 4, 753 | "nbformat_minor": 4 754 | } 755 | -------------------------------------------------------------------------------- /pycodesign.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding: utf-8 3 | 4 | 5 | 6 | 7 | 8 | 9 | version = '0.3' 10 | 11 | import logging 12 | import configparser 13 | import argparse 14 | # from distutils import util 15 | import subprocess 16 | import shlex 17 | import tempfile 18 | from pathlib import Path 19 | from time import sleep 20 | import sys 21 | from shutil import rmtree 22 | 23 | 24 | 25 | 26 | 27 | 28 | def strtobool (val): 29 | """Convert a string representation of truth to true (1) or false (0). 30 | True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values 31 | are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if 32 | 'val' is anything else. 33 | 34 | courtesy of 35 | https://github.com/python/cpython/blob/main/Lib/distutils/util.py 36 | """ 37 | val = val.lower() 38 | if val in ('y', 'yes', 't', 'true', 'on', '1'): 39 | return 1 40 | elif val in ('n', 'no', 'f', 'false', 'off', '0'): 41 | return 0 42 | else: 43 | raise ValueError("invalid truth value %r" % (val,)) 44 | 45 | 46 | 47 | 48 | 49 | 50 | def get_config(args, default_config=None, filename='pycodesign.ini'): 51 | file = args.config 52 | config = configparser.ConfigParser() 53 | 54 | if file: 55 | print (f'using configuration file: {file}') 56 | config.read(file) 57 | elif default_config and args.new_config: 58 | config.read_dict(default_config) 59 | print(f'writing default config file: {filename}') 60 | try: 61 | with open(filename, 'w') as blank_config: 62 | config.write(blank_config) 63 | except OSError as e: 64 | print(f'could not create {filename} due to error: {e}') 65 | return {} 66 | else: 67 | return {} 68 | 69 | return {s:dict(config.items(s)) for s in config.sections()} 70 | 71 | 72 | 73 | 74 | 75 | 76 | def get_args(): 77 | 78 | saved =[] 79 | for k in sys.argv: 80 | saved.append(k) 81 | 82 | if '-f' in saved: 83 | logging.info('working in interactive jupyter environ') 84 | try: 85 | sys.argv = sys.argv[:sys.argv.index('-f')] 86 | except ValueError as e: 87 | pass 88 | 89 | 90 | 91 | parser = argparse.ArgumentParser(description='PyCodeSign -- Code Signing and Notarization Assistant') 92 | 93 | parser.add_argument('-v', '--verbose', action='count', default=1) 94 | 95 | parser.add_argument('-V', '--version', dest='version', 96 | action='store_true', default=False) 97 | 98 | parser.add_argument('-N', '--new', dest='new_config', 99 | action='store_true', default=False, 100 | help='create a new sample configuration with name "pycodesign.ini" in current directory') 101 | 102 | parser.add_argument('config', nargs='?', type=str, default=None, 103 | help='configuration file to use when codesigning', 104 | metavar='') 105 | 106 | parser.add_argument('-s', '--sign', dest='sign_only', 107 | action='store_true', default=None, 108 | help='sign the executables, but take no further action (can be combined with -p, -n, -t)') 109 | 110 | parser.add_argument('-p', '--package', dest='package_only', 111 | action='store_true', default=None, 112 | help='package the executables, but take no further action (can be combined with -s, -n, -t)') 113 | 114 | parser.add_argument('-P', '--package_debug', dest='package_debug', 115 | action='store_true', default=None, 116 | help='package but leave temporary files in place for debugging') 117 | 118 | parser.add_argument('-n', '--notarize', dest='notarize_only', 119 | action='store_true', default=None, 120 | help='notarize the package, but take no further action (can be combined with -s, -p, -t)') 121 | 122 | parser.add_argument('-t', '--staple', dest='staple_only', 123 | action='store_true', default=None, 124 | help='stape the notarization to the the package, but take no further action (can be combined with -s, -p, -n)') 125 | 126 | #parser.add_argument('-T', '--notarize_timer', type=int, default=60, 127 | # metavar="", 128 | # help='base time in seconds to wait between checking notarization status with apple (default 60)') 129 | 130 | #parser.add_argument('-C', '--num_checks', type=int, default=5, 131 | # metavar="", 132 | # help='number of times to check notarization status with apple (default 5) -- each check doubles notarize_timer') 133 | 134 | parser.add_argument('-O', '--pkg_version', type=str, 135 | default=None, 136 | metavar="", 137 | help='overide the version number in the .ini file and use supplied version number.') 138 | 139 | # known_args, unknown_args = parser.parse_known_args() 140 | args = parser.parse_args() 141 | # return(known_args, unknown_args) 142 | return args 143 | 144 | 145 | 146 | 147 | 148 | 149 | def validate_config(config, expected_keys): 150 | missing = {} 151 | for section, keys in expected_keys.items(): 152 | if not section in config.keys(): 153 | missing[section] = expected_keys[section] 154 | continue 155 | for key in keys: 156 | if not key in config[section].keys(): 157 | if not section in missing: 158 | missing[section] = {} 159 | missing[section][key] = keys[key] 160 | 161 | if missing: 162 | print('Config file is missing values:') 163 | for section, values in missing.items(): 164 | print(f'[{section}]') 165 | for k, v in values.items(): 166 | print(f'\t{k}: {v}') 167 | 168 | return missing 169 | 170 | 171 | 172 | 173 | 174 | 175 | def run_command(command_list): 176 | cmd = subprocess.Popen(shlex.split(' '.join(command_list)), 177 | stdout=subprocess.PIPE, 178 | stderr=subprocess.PIPE) 179 | stderr, stdout = cmd.communicate() 180 | return cmd.returncode, stderr, stdout 181 | 182 | 183 | 184 | 185 | 186 | 187 | def sign(config): 188 | 189 | try: 190 | entitlements = strtobool(config['package_details']['entitlements']) 191 | except (AttributeError, ValueError): 192 | entitlements = config['package_details']['entitlements'] 193 | 194 | if (not entitlements) or (entitlements == 'None'): 195 | entitlements = None 196 | 197 | config['package_details']['entitlements'] = entitlements 198 | 199 | args = { 200 | 'command': 'codesign', 201 | 'args': '--deep --force --timestamp --options=runtime', 202 | 'entitlements': f'--entitlements {config["package_details"]["entitlements"]}' if config["package_details"]["entitlements"] else None, 203 | 'signature': f'--sign {config["identification"]["application_id"]}', 204 | 'files': ' '.join(config['package_details']['file_list']) 205 | } 206 | 207 | final_list = [i if i is not None else '' for k, i in args.items()] 208 | logging.debug('running command:') 209 | logging.debug(' '.join(final_list)) 210 | 211 | print(f'signing files: {args["files"]}') 212 | 213 | return_code, stdout, stderr = run_command(final_list) 214 | logging.debug(f'return code: {return_code}') 215 | logging.debug(f'stdout: {stdout}') 216 | logging.debug(f'stderr: {stderr}') 217 | 218 | return return_code, stdout, stderr 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | def package(config, package_debug=False): 227 | pkg_temp = Path(tempfile.mkdtemp()).resolve() 228 | 229 | install_path = Path(config['package_details']['installation_path']).resolve() 230 | 231 | temp_path = Path(f'{pkg_temp}/{install_path}') 232 | 233 | # return pkg_temp, install_path 234 | 235 | logging.debug(f'pkg_temp: {pkg_temp}') 236 | logging.debug(f'install_path: {install_path}') 237 | logging.debug(f'temp_path: {temp_path}') 238 | 239 | for file in config['package_details']['file_list']: 240 | my_file = Path(file).resolve() 241 | file_name = my_file.name 242 | 243 | command = f'ditto {my_file} {temp_path/file_name}' 244 | logging.debug(f'running command: {command}') 245 | r, o, e = run_command(shlex.split(command)) 246 | if not process_return(r, o, e): 247 | logging.warning('could not ditto file into temp path') 248 | if not package_debug: 249 | rmtree(pkg_temp) 250 | return r, o, e 251 | 252 | args = { 253 | 'command': 'productbuild', 254 | 'identifier': f'--identifier {config["package_details"]["bundle_id"]}.pkg', 255 | 'signature': f'--sign {config["identification"]["installer_id"]}', 256 | 'args': '--timestamp', 257 | 'version': f'--version {config["package_details"]["version"]}', 258 | 'root': f'--root {pkg_temp} / ./{config["package_details"]["package_name"]}.pkg' 259 | 260 | } 261 | 262 | print(f'packaging {config["package_details"]["package_name"]}.pkg') 263 | final_list = [i if i is not None else '' for k, i in args.items()] 264 | 265 | logging.debug('running command:') 266 | logging.debug(' '.join(final_list)) 267 | 268 | r, o, e = run_command(final_list) 269 | 270 | 271 | # logging.debug(f'return code: {return_code}') 272 | # logging.debug(f'stdout: {stdout}') 273 | # logging.debug(f'stderr: {stderr}') 274 | 275 | if not package_debug: 276 | rmtree(pkg_temp, ignore_errors=True) 277 | else: 278 | print(f'Package debugging active:') 279 | print(f'Temp files: {pkg_temp}') 280 | return r, o, e 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | # def package(config, package_debug=False): 290 | # pkg_temp = tempfile.mkdtemp() 291 | # pkg_temp_path = Path(pkg_temp).resolve() 292 | # install_path = Path(config['package_details']['installation_path']).resolve() 293 | # logging.debug(f'using pkg_temp_path: {pkg_temp_path}') 294 | # logging.debug(f'install_path: {install_path}') 295 | # for file in config['package_details']['file_list']: 296 | # my_file = Path(file).resolve() 297 | # file_name = my_file.name 298 | # logging.debug(f'copying file: {file}') 299 | # command = f'ditto {file} {pkg_temp_path/install_path/file_name}' 300 | # logging.debug(f'run command:\n {command}') 301 | # return_code, stderr, stdout = run_command(shlex.split(command)) 302 | # if return_code > 0: 303 | # pkg_temp.cleanup() 304 | # return return_code, stderr, stdout 305 | 306 | # args = { 307 | # 'command': 'productbuild', 308 | # 'identifier': f'--identifier {config["package_details"]["bundle_id"]}.pkg', 309 | # 'signature': f'--sign {config["identification"]["installer_id"]}', 310 | # 'args': '--timestamp', 311 | # 'version': f'--version {config["package_details"]["version"]}', 312 | # 'root': f'--root {pkg_temp_path} / ./{config["package_details"]["package_name"]}.pkg' 313 | 314 | # } 315 | 316 | # print(f'packaging {config["package_details"]["package_name"]}.pkg') 317 | # final_list = [i if i is not None else '' for k, i in args.items()] 318 | 319 | # logging.debug('running command:') 320 | # logging.debug(' '.join(final_list)) 321 | 322 | # return_code, stdout, stderr = run_command(final_list) 323 | 324 | # logging.debug(f'return code: {return_code}') 325 | # logging.debug(f'stdout: {stdout}') 326 | # logging.debug(f'stderr: {stderr}') 327 | 328 | # if not package_debug: 329 | # rmtree(pkg_temp_path, ignore_errors=True) 330 | # else: 331 | # print(f'Package debugging active:') 332 | # print(f'Temp files: {pkg_temp_path}') 333 | # return return_code, stdout, stderr 334 | 335 | 336 | 337 | 338 | 339 | 340 | def notarize(config): 341 | notarize_args = { 342 | 'command': 'xcrun notarytool', 343 | 'args': 'submit --wait', 344 | #'bundle_id': f'--primary-bundle-id {config["package_details"]["bundle_id"]}', 345 | #'username': f'--username={config["identification"]["apple_id"]}', 346 | #'password': f'--password {config["identification"]["password"]}', 347 | 'keychain-profile': f'--keychain-profile {config["identification"]["keychain-profile"]}', 348 | 'file': f'{config["package_details"]["package_name"]}.pkg' 349 | } 350 | 351 | 352 | 353 | final_list = [i for k, i, in notarize_args.items()] 354 | logging.debug('running command:') 355 | logging.debug(' '.join(final_list)) 356 | 357 | return_code, stdout, stderr = run_command(final_list) 358 | 359 | logging.debug(f'return code: {return_code}') 360 | logging.debug(f'stdout: {stdout}') 361 | logging.debug(f'stderr: {stderr}') 362 | 363 | return return_code, stdout, stderr 364 | 365 | 366 | 367 | 368 | 369 | 370 | def check_notarization(stdout, config): 371 | notarize_max_check = config['main']['notrarize_max_check'] 372 | notarize_check = 0 373 | notarized = False 374 | 375 | 376 | uuids = [] 377 | for line in str(stdout, 'utf-8').splitlines(): 378 | if 'requestuuid' in line.lower(): 379 | my_id = line.split('=') 380 | uuids.append(my_id[1].strip()) 381 | logging.debug('uuids found: ') 382 | logging.debug(uuids) 383 | 384 | check_args = { 385 | 'command': 'xcrun altool', 386 | 'info': f'--notarization-info {uuids[0]}', 387 | 'username': f'--username {config["identification"]["apple_id"]}', 388 | 'password': f'--password {config["identification"]["password"]}' 389 | } 390 | final_list = [i for k, i in check_args.items()] 391 | 392 | 393 | while not notarized: 394 | status = {} 395 | success = None 396 | print('checking notarization status') 397 | notarize_check += 1 398 | print(f'check: {notarize_check} of {notarize_max_check}') 399 | 400 | logging.debug('running command:') 401 | logging.debug(' '.join(final_list)) 402 | 403 | return_code, stdout, stderr = run_command(final_list) 404 | 405 | logging.debug(f'return code: {return_code}') 406 | logging.debug(f'stdout: {stdout}') 407 | logging.debug(f'stderr: {stderr}') 408 | 409 | if stdout: 410 | lines = str(stdout, 'utf-8').splitlines() 411 | 412 | for l in lines: 413 | if 'status' in l.lower(): 414 | vals = l.split(':') 415 | status[vals[0].strip().lower()] = vals[1].strip() 416 | 417 | try: 418 | if status['status'] == 'success': 419 | success = True 420 | if status['status'] == 'invalid': 421 | success = False 422 | except KeyError as e: 423 | logging.debug(f'inconclusive notarization status data returned: {status}') 424 | 425 | 426 | logging.debug('status: ') 427 | logging.debug(status) 428 | 429 | if success is True: 430 | logging.debug('successfully notarized') 431 | notarized=True 432 | elif success is False: 433 | logging.debug('notarization failed') 434 | break 435 | else: 436 | print(f'notarization not complete: {status}') 437 | if notarize_check >= notarize_max_check-1: 438 | print('notarization failed') 439 | break 440 | sleep_timer = config['main']['notarize_timer']*notarize_check 441 | print(f'sleeping for {sleep_timer} seconds') 442 | logging.debug(f'notarization not complete; sleeping for {sleep_timer}') 443 | sleep(sleep_timer) 444 | return notarized 445 | 446 | 447 | 448 | 449 | 450 | 451 | def staple(config): 452 | args = { 453 | 'command': 'xcrun stapler', 454 | 'args': 'staple', 455 | 'package': f'{config["package_details"]["package_name"]}.pkg' 456 | } 457 | 458 | final_list = [i for k, i in args.items()] 459 | 460 | logging.debug('running command:') 461 | logging.debug(' '.join(final_list)) 462 | 463 | 464 | return_code, stdout, stderr = run_command(final_list) 465 | 466 | logging.debug(f'return code: {return_code}') 467 | logging.debug(f'stdout: {stdout}') 468 | logging.debug(f'stderr: {stderr}') 469 | 470 | 471 | return return_code, stdout, stderr 472 | 473 | 474 | 475 | 476 | 477 | 478 | def process_return(return_value, stdout, stderr): 479 | def byte_print(byte_str): 480 | if isinstance(byte_str, bytes): 481 | for line in str(byte_str, 'utf-8').splitlines(): 482 | print(line) 483 | else: 484 | print(byte_str) 485 | 486 | 487 | if len(stdout) > 0: 488 | print('OUTPUT: ') 489 | byte_print(stdout) 490 | if len(stderr) > 0: 491 | print('ERRORS:') 492 | byte_print(stderr) 493 | 494 | if return_value > 0: 495 | retval = False 496 | print('failed\n\n') 497 | else: 498 | retval = True 499 | print('success\n\n') 500 | 501 | return retval 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | ## Testing code 510 | #sys.argv = sys.argv[:1] 511 | 512 | # sys.argv.extend(['-O', '9.9.9']) 513 | # sys.argv.append('insert_files_codesign.ini') 514 | 515 | 516 | # expected_config_keys = { 517 | # 'identification': { 518 | # 'application_id': 'Unique Substring of Developer ID Application Cert', 519 | # 'installer_id': 'Unique Substring of Developer ID Installer Cert', 520 | # 'apple_id': 'developer@domain.com', 521 | # 'password': '@keychain:App-Specific-Password-Name-In-Keychain', 522 | # }, 523 | # 'package_details': { 524 | # 'package_name': 'nameofpackage', 525 | # 'bundle_id': 'com.developer.packagename', 526 | # 'file_list': "include_file1, include_file2", 527 | # 'installation_path': '/Applications/', 528 | # 'entitlements': 'None', 529 | # 'version': '0.0.0' 530 | # } 531 | # } 532 | 533 | # logging.root.setLevel("DEBUG") 534 | # args = get_args() 535 | # config = get_config(args=args, default_config=expected_config_keys) 536 | 537 | # config.update({'main': { 538 | # 'notarize_timer': args.notarize_timer, 539 | # 'notrarize_max_check': args.num_checks, 540 | # 'new_config': args.new_config} 541 | # }) 542 | 543 | # if args.pkg_version: 544 | # config['package_details']['version'] = args.pkg_version 545 | 546 | # validate_config(config, expected_config_keys) 547 | 548 | 549 | 550 | 551 | 552 | 553 | def main(): 554 | logger = logging.getLogger(__name__) 555 | 556 | expected_config_keys = { 557 | 'identification': { 558 | 'application_id': 'Unique Substring of Developer ID Application Cert', 559 | 'installer_id': 'Unique Substring of Developer ID Installer Cert', 560 | 'keychain-profile': 'Name-of-stored-keychain-profile' 561 | }, 562 | 'package_details': { 563 | 'package_name': 'nameofpackage', 564 | 'bundle_id': 'com.developer.packagename', 565 | 'file_list': "include_file1, include_file2", 566 | 'installation_path': '/Applications/', 567 | 'entitlements': 'None', 568 | 'version': '0.0.0' 569 | } 570 | } 571 | run_all = True 572 | 573 | # notarize_timer = 60 574 | # notrarize_max_check = 5 575 | halt = False 576 | 577 | args = get_args() 578 | 579 | verbose = 50 - (args.verbose*10) 580 | if verbose < 10: 581 | verbose = 10 582 | logging.root.setLevel(verbose) 583 | 584 | if args.version: 585 | print(f'{sys.argv[0]} V{version}') 586 | return 587 | 588 | config = get_config(args=args, default_config=expected_config_keys) 589 | if not config: 590 | print('no configuration file provided') 591 | print(f'try:\n$ {sys.argv[0]} -h') 592 | return 593 | 594 | #config.update({'main': { 595 | # 'notarize_timer': args.notarize_timer, 596 | # 'notrarize_max_check': args.num_checks, 597 | # 'new_config': args.new_config} 598 | # }) 599 | 600 | if args.pkg_version: 601 | config['package_details']['version'] = args.pkg_version 602 | 603 | logging.debug('using config:') 604 | logging.debug(config) 605 | 606 | if validate_config(config, expected_config_keys): 607 | print('exiting') 608 | return 609 | 610 | # split the file list into an actual list 611 | try: 612 | file_list = config['package_details']['file_list'].split(',') 613 | config['package_details']['file_list'] = file_list 614 | except KeyError: 615 | pass 616 | 617 | 618 | check_args =[args.notarize_only, 619 | args.package_only, 620 | args.sign_only, 621 | args.staple_only, 622 | args.package_debug ] 623 | 624 | for each in check_args: 625 | if each: 626 | run_all = False 627 | 628 | # if args.notarize_only or args.package_only or args.sign_only or args.staple_only or args.package_debug: 629 | # run_all = False 630 | 631 | if args.sign_only or run_all: 632 | print('signing...') 633 | r, o, e = sign(config) 634 | process_return(r, o, e) 635 | if r > 0: 636 | halt = True 637 | 638 | if args.package_only or args.package_debug or run_all and not halt: 639 | print('packaging...') 640 | r, o, e = package(config, args.package_debug) 641 | process_return(r, o, e) 642 | if r > 0: 643 | halt = True 644 | 645 | if args.notarize_only or run_all and not halt: 646 | print('notarizing...') 647 | r, o, e = notarize(config) 648 | process_return(r, o, e) 649 | if r == 0: 650 | print('notaization process at Apple completed') 651 | else: 652 | print('notariztion process did not complete or was inconclusive') 653 | print(f'check manually with: ') 654 | print(f'xcrun notarytool history --keychain-profile {config["identification"]["keychain-profile"]}') 655 | halt = True 656 | 657 | if args.staple_only or run_all and not halt: 658 | print('stapling...') 659 | r, o, e = staple(config) 660 | process_return(r, o, e) 661 | if r > 0: 662 | halt = True 663 | 664 | 665 | return config 666 | 667 | 668 | 669 | 670 | 671 | 672 | 673 | if __name__ == '__main__': 674 | c = main() 675 | 676 | 677 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | -------------------------------------------------------------------------------- /pycodesign.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/txoof/codesign/72f244cf352364b34ae46af5a7cfe7cd30425cc0/pycodesign.tgz --------------------------------------------------------------------------------