Revision: 25686 http://sourceforge.net/p/bibdesk/svn/25686 Author: hofman Date: 2021-04-03 14:11:50 +0000 (Sat, 03 Apr 2021) Log Message: ----------- Add a python script to build a BibDesk release, including codesigning and notaraization. Should be called with the codesign identity and username for notarization.
Added Paths: ----------- trunk/bibdesk/build_release.py Added: trunk/bibdesk/build_release.py =================================================================== --- trunk/bibdesk/build_release.py (rev 0) +++ trunk/bibdesk/build_release.py 2021-04-03 14:11:50 UTC (rev 25686) @@ -0,0 +1,525 @@ +#!/usr/bin/python + +# +# This script is part of BibDesk. Various paths are hardcoded near +# the top, along with other paths that are dependent on those. The script +# builds, codesigns, and notarizes a zip compressed app bundle for release +# and create an appcast item for the release. +# + +# +# SYNOPSIS +# build_release.sh [-i identity] [-u username] [-p password] [-o out] +# +# OPTIONS +# -i --identity +# Codesign identity, not codesigned when empty +# -u, --username +# Username for notarization, not notarized when empty +# -p, --password +# Password for notarization, defaults to @keychain:AC_PASSWORD +# -o, --out +# Output directory for the final zip and appcast, defaults to the user's Desktop +# + +# +# Created by Adam Maxwell on 12/28/08. +# +# This software is Copyright (c) 2008-2021 +# Adam Maxwell. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# - Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# - Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# - Neither the name of Adam Maxwell nor the names of any +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# + +import os, sys, io, getopt +from subprocess import Popen, PIPE +from stat import ST_SIZE +import tarfile +from time import gmtime, strftime, localtime, sleep +import plistlib +import tempfile +from getpass import getuser + +from Foundation import NSXMLDocument, NSUserDefaults, NSURL, NSXMLNodePrettyPrint, NSXMLNodePreserveCDATA + +# determine the path based on the path of this program +SOURCE_DIR = os.path.dirname(os.path.abspath(sys.argv[0])) +assert len(SOURCE_DIR) +assert SOURCE_DIR.startswith("/") + +# name of secure note in Keychain +KEY_NAME = "BibDesk Sparkle Key" + +APPCAST_URL = "https://bibdesk.sourceforge.io/bibdesk.xml" + +# create a private temporary directory +BUILD_ROOT = os.path.join("/tmp", "BibDesk-%s" % (getuser())) +try: + # should already exist after the first run + os.mkdir(BUILD_ROOT) +except Exception as e: + assert os.path.isdir(BUILD_ROOT), "%s does not exist" % (BUILD_ROOT) + +# derived paths +SYMROOT = os.path.join(BUILD_ROOT, "Products") +BUILD_DIR = os.path.join(SYMROOT, "Release") +BUILT_APP = os.path.join(BUILD_DIR, "BibDesk.app") +DERIVED_DATA_DIR = os.path.join(BUILD_ROOT, "DerivedData") +PLIST_PATH = os.path.join(BUILT_APP, "Contents", "Info.plist") + +def read_versions(): + + # read CFBundleVersion, CFBundleShortVersionString, LSMinimumSystemVersion and from Info.plist + infoPlist = plistlib.readPlist(PLIST_PATH) + assert infoPlist is not None, "unable to read Info.plist" + newVersion = infoPlist["CFBundleVersion"] + newVersionString = infoPlist["CFBundleShortVersionString"] + minimumSystemVersion = infoPlist["LSMinimumSystemVersion"] + assert newVersion is not None, "unable to read old version from Info.plist" + assert newVersionString is not None, "unable to read old version from Info.plist" + + return newVersion, newVersionString , minimumSystemVersion + +def clean_and_build(): + + # clean and rebuild the Xcode project + buildCmd = ["/usr/bin/xcodebuild", "clean", "-configuration", "Release", "-target", "BibDesk", "-scheme", "BibDesk", "-derivedDataPath", DERIVED_DATA_DIR, "SYMROOT=" + SYMROOT] + nullDevice = open("/dev/null", "w") + x = Popen(buildCmd, cwd=SOURCE_DIR) + rc = x.wait() + print("xcodebuild clean exited with status %s" % (rc)) + + buildCmd = ["/usr/bin/xcodebuild", "-configuration", "Release", "-target", "BibDesk", "-scheme", "BibDesk", "-derivedDataPath", DERIVED_DATA_DIR, "SYMROOT=" + SYMROOT, "CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO"] + nullDevice = open("/dev/null", "w") + x = Popen(buildCmd, cwd=SOURCE_DIR)#, stdout=nullDevice, stderr=nullDevice) + rc = x.wait() + assert rc == 0, "xcodebuild failed" + nullDevice.close() + +def codesign(identity): + + sign_cmd = [os.path.join(SOURCE_DIR, "codesign_bibdesk.sh"), identity, BUILT_APP] + x = Popen(sign_cmd, cwd=SOURCE_DIR) + rc = x.wait() + print("codesign_bibdesk.sh exited with status %s" % (rc)) + assert rc == 0, "code signing failed" + +def notarize_dmg_or_zip(dmg_path, username, password): + """dmg_path: zip file or dmg file""" + + notarize_cmd = ["xcrun", "altool", "--notarize-app", "--primary-bundle-id", "net.sourceforce.bibdesk.zip", "--username", username, "--password", password, "--output-format", "xml", "--file", dmg_path] + notarize_task = Popen(notarize_cmd, cwd=SOURCE_DIR, stdout=PIPE, stderr=PIPE) + [output, error] = notarize_task.communicate() + rc = notarize_task.returncode + print("altool --notarize-app exited with status %s" % (rc)) + assert rc == 0, "notarization failed" + + output_stream = io.BytesIO(output) + output_pl = plistlib.readPlist(output_stream) + output_stream.close() + sys.stderr.write("%s\n" % (output)) + assert "notarization-upload" in output_pl, "missing notarization-upload key in reply %s" % (output) + + request_uuid = output_pl["notarization-upload"]["RequestUUID"] + + while True: + + sleep(20) + + notarize_cmd = ["xcrun", "altool", "--notarization-info", request_uuid, "--username", username, "--password", password, "--output-format", "xml"] + notarize_task = Popen(notarize_cmd, cwd=SOURCE_DIR, stdout=PIPE, stderr=PIPE) + [output, error] = notarize_task.communicate() + rc = notarize_task.returncode + assert rc == 0, "status request failed" + + output_stream = io.BytesIO(output) + output_pl = plistlib.readPlist(output_stream) + assert "notarization-info" in output_pl, "missing notarization-upload key in reply %s" % (output) + status = output_pl["notarization-info"]["Status"] + + if status == "invalid": + # open the URL + log_url = output_pl["notarization-info"]["LogFileURL"] + Popen(["/usr/bin/open", log_url]) + break + elif status == "in progress": + sys.stderr.write("notarization status not available yet for %s\n" % (request_uuid)) + continue + else: + # staple? + sys.stderr.write("notarization succeeded\n") + sys.stdout.write("%s\n" % (output)) + + log_url = output_pl["notarization-info"]["LogFileURL"] + Popen(["/usr/bin/open", log_url]) + + break + +def create_dmg_of_application(new_version_number): + + # Create a name for the dmg based on version number, instead + # of date, since I sometimes want to upload multiple betas per day. + final_dmg_name = os.path.join(BUILD_DIR, os.path.splitext(os.path.basename(BUILT_APP))[0] + "-" + new_version_number + ".dmg") + + temp_dmg_path = "/tmp/BibDesk.dmg" + if os.path.exists(temp_dmg_path): + os.unlink(temp_dmg_path) + + nullDevice = open("/dev/null", "w") + cmd = ["/usr/bin/hdiutil", "create", "-fs", "HFS+", "-srcfolder", BUILT_APP, temp_dmg_path] + x = Popen(cmd, stdout=nullDevice, stderr=nullDevice) + rc = x.wait() + assert rc == 0, "hdiutil create failed" + + cmd = ["/usr/bin/hdiutil", "convert", temp_dmg_path, "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", final_dmg_name] + x = Popen(cmd, stdout=nullDevice, stderr=nullDevice) + rc = x.wait() + assert rc == 0, "hdiutil convert failed" + + nullDevice.close() + os.unlink(temp_dmg_path) + + return final_dmg_name + +def prepare_dmg_of_application(new_version_number): + + # Create a name for the dmg based on version number, instead + # of date, since I sometimes want to upload multiple betas per day. + final_dmg_name = os.path.join(BUILD_DIR, os.path.splitext(os.path.basename(BUILT_APP))[0] + "-" + new_version_number + ".dmg") + + # template image in source folder + zip_dmg_name = os.path.join(SOURCE_DIR, "BibDesk.dmg.zip") + + # temporary image + temp_dmg_path = "/tmp/BibDesk.dmg" + + # temporary volume + dst_volume_name = "/Volumes/BibDesk" + + # see if this file already exists and bail + assert not os.path.exists(final_dmg_name), "%s exists" % (final_dmg_name) + + # see if a volume is already mounted or a + # previous cp operation was botched + assert not os.path.exists(dst_volume_name), "%s exists" % (dst_volume_name) + + nullDevice = open("/dev/null", "w") + + # remove temp image from a previous run + if os.path.exists(temp_dmg_path): + unlink(temp_dmg_path) + + # stored zipped in svn, so unzip if needed + # pass o to overwrite, or unzip waits for stdin + # when trying to unpack the resource fork/EA + + cmd = ["/usr/bin/unzip", "-uo", zip_dmg_name, "-d", "/tmp"] + x = Popen(cmd, stdout=nullDevice, stderr=nullDevice) + rc = x.wait() + assert rc == 0, "failed to unzip %s" % (zip_dmg_name) + + # mount image + cmd = ["/usr/bin/hdiutil", "attach", "-nobrowse", "-noautoopen", temp_dmg_path] + x = Popen(cmd, stdout=nullDevice, stderr=nullDevice) + rc = x.wait() + assert rc == 0, "failed to mount %s" % (temp_dmg_path) + + # use cp to copy all files + cmd = ["/bin/cp", "-R", BUILT_APP, dst_volume_name] + x = Popen(cmd, stdout=nullDevice, stderr=nullDevice) + rc = x.wait() + assert rc == 0, "failed to copy %s" % (BUILT_APP) + + # tell finder to set the icon position + cmd = ["/usr/bin/osascript", "-e", """tell application "Finder" to set the position of application file "BibDesk.app" of disk named "BibDesk" to {204, 148}"""] + x = Popen(cmd, stdout=nullDevice, stderr=nullDevice) + rc = x.wait() + assert rc == 0, "Finder failed to set position" + + # data is copied, so unmount the volume, we may need to wait when the volume is in use + n_tries = 0 + cmd = ["/usr/sbin/diskutil", "eject", dst_volume_name] + x = Popen(cmd, stdout=nullDevice, stderr=nullDevice) + rc = x.wait() + while rc != 0: + assert n_tries < 12, "failed to eject %s" % (dst_volume_name) + n_tries += 1 + sleep(5) + x = Popen(cmd, stdout=nullDevice, stderr=nullDevice) + rc = x.wait() + + # resize image to fit + cmd = ["/usr/bin/hdiutil", "resize", temp_dmg_path] + x = Popen(cmd, stdout=PIPE, stderr=nullDevice) + size = x.communicate()[0].split(None, 1)[0] + cmd = ["/usr/bin/hdiutil", "resize", "-size", size + "b", temp_dmg_path] + x = Popen(cmd, stdout=nullDevice, stderr=nullDevice) + assert rc == 0, "failed to resize %s" % (temp_dmg_path) + + # convert image to read only and compress + cmd = ["/usr/bin/hdiutil", "convert", temp_dmg_path, "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", final_dmg_name] + x = Popen(cmd, stdout=nullDevice, stderr=nullDevice) + rc = x.wait() + assert rc == 0, "failed to convert %s" % (temp_dmg_path) + + # remove temp image + nullDevice.close() + os.unlink(temp_dmg_path) + +def create_zip_of_application(new_version_number): + + # Create a name for the zip file based on version number, instead + # of date, since I sometimes want to upload multiple betas per day. + final_zip_name = os.path.join(BUILD_DIR, os.path.splitext(os.path.basename(BUILT_APP))[0] + "-" + new_version_number + ".zip") + + nullDevice = open("/dev/null", "w") + cmd = ["/usr/bin/ditto", "-c", "-k", "--keepParent", BUILT_APP, final_zip_name] + x = Popen(cmd) + rc = x.wait() + assert rc == 0, "zip creation failed" + + return final_zip_name + +def keyFromSecureNote(): + + # see http://www.entropy.ch/blog/Developer/2008/09/22/Sparkle-Appcast-Automation-in-Xcode.html + pwtask = Popen(["/usr/bin/security", "find-generic-password", "-g", "-s", KEY_NAME], stdout=PIPE, stderr=PIPE) + [output, error] = pwtask.communicate() + pwoutput = output + error + + # notes are evidently stored as archived RTF data, so find start/end markers + start = pwoutput.find("-----BEGIN DSA PRIVATE KEY-----") + stopString = "-----END DSA PRIVATE KEY-----" + stop = pwoutput.find(stopString) + + assert start is not -1 and stop is not -1, "failed to find DSA key in secure note" + + key = pwoutput[start:stop] + stopString + + # replace RTF end-of-lines + key = key.replace("\\134\\012", "\n") + key = key.replace("\\012", "\n") + + return key + +def signature_and_size(dmg_or_zip_path): + + # write to a temporary file that's readably only by owner; minor security issue here since + # we have to use a named temp file, but it's better than storing unencrypted key + keyFile = tempfile.NamedTemporaryFile() + keyFile.write(keyFromSecureNote()) + keyFile.flush() + + # now run the signature for Sparkle... + sha_task = Popen(["/usr/bin/openssl", "dgst", "-sha1", "-binary"], stdin=open(dmg_or_zip_path, "rb"), stdout=PIPE) + dss_task = Popen(["/usr/bin/openssl", "dgst", "-dss1", "-sign", keyFile.name], stdin=sha_task.stdout, stdout=PIPE) + b64_task = Popen(["/usr/bin/openssl", "enc", "-base64"], stdin=dss_task.stdout, stdout=PIPE) + + # now compute the variables we need for writing the new appcast + appcastSignature = b64_task.communicate()[0].strip() + fileSize = str(os.stat(dmg_or_zip_path)[ST_SIZE]) + + return appcastSignature, fileSize + +def write_appcast(newVersion, newVersionString, minimumSystemVersion, dmg_or_zip_path, outputPath): + + appcast_signature, fileSize = signature_and_size(dmg_or_zip_path) + download_url = "https://sourceforge.net/projects/bibdesk/files/BibDesk/BibDesk-" + newVersionString + "/" + os.path.basename(dmg_or_zip_path) + "/download" + appcastDate = strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime()) + if dmg_or_zip_path.endswith("dmg"): + type = "application/x-apple-diskimage" + else: + type = "application/zip" + + # creating this from a string is easier than manipulating NSXMLNodes... + newItemString = """<?xml version="1.0" encoding="utf-8"?> + <rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/"> + <channel> + <item> + <title>Version """ + newVersionString + """</title> + <description> + <![CDATA[ + <h1>Version ${VERSION}</h1> + + <h2>New Features</h2> + <ul> + <li></li> + </ul> + + <h2>Bugs Fixed</h2> + <ul> + <li></li> + </ul> + ]]> + </description> + <pubDate>""" + appcastDate + """</pubDate> + <sparkle:minimumSystemVersion>""" + minimumSystemVersion + """</sparkle:minimumSystemVersion> + <enclosure url=\"""" + download_url + """\" sparkle:version=\"""" + newVersion + """\" sparkle:shortVersionString=\"""" + newVersionString + """\" length=\"""" + fileSize + """\" type=\"""" + type + """\" sparkle:dsaSignature=\"""" + appcastSignature + """\" /> + </item> + </channel> + </rss> + """ + + # read from the source directory + appcastURL = NSURL.URLWithString_(APPCAST_URL) + + # xml doc from the current appcast + (oldDoc, error) = NSXMLDocument.alloc().initWithContentsOfURL_options_error_(appcastURL, NSXMLNodePreserveCDATA, None) + assert oldDoc is not None, error + + # xml doc from the new appcast string + (newDoc, error) = NSXMLDocument.alloc().initWithXMLString_options_error_(newItemString, NSXMLNodePreserveCDATA, None) + assert newDoc is not None, error + + # get an arry of the current item titles + (oldTitles, error) = oldDoc.nodesForXPath_error_("//item/title", None) + assert oldTitles.count > 0, "oldTitles had no elements" + + # now get the title we just created + (newTitles, error) = newDoc.nodesForXPath_error_("//item/title", None) + assert newTitles.count() is 1, "newTitles must have a single element" + + # easy test to avoid duplicating items + if oldTitles.containsObject_(newTitles.lastObject()) is False: + + # get the parent node we'll be inserting to + (parentChannel, error) = oldDoc.nodesForXPath_error_("//channel", None) + assert parentChannel.count() is 1, "channel count must be one" + parentChannel = parentChannel.lastObject() + + # now get the new node + (newNodes, error) = newDoc.nodesForXPath_error_("//item", None) + assert newNodes is not None, error + + # insert a copy of the new node + parentChannel.addChild_(newNodes.lastObject().copy()) + + # write to user Desktop + appcastPath = os.path.join(outputPath , "bibdesk.xml") + + # write to NSData, since pretty printing didn't work with NSXMLDocument writing + oldDoc.XMLDataWithOptions_(NSXMLNodePrettyPrint).writeToFile_atomically_(appcastPath, True) + + else: + + appcastPath = os.path.join(outputPath , "BibDesk-" + newVersionString + ".xml") + appcastFile = open(appcastPath, "w") + appcastFile.write(newItemString) + appcastFile.close() + +def user_and_pass_for_upload(): + + pwtask = Popen(["/usr/bin/security", "find-internet-password", "-g", "-s", UPLOAD_KEYCHAIN_ITEM, "-t", "dflt"], stdout=PIPE, stderr=PIPE) + [output, error] = pwtask.communicate() + pwoutput = output + error + + username = None + password = None + for line in pwoutput.split("\n"): + line = line.strip() + acct_prefix = "\"acct\"<blob>=" + pw_prefix = "password: " + if line.startswith(acct_prefix): + assert username == None, "already found username" + username = line[len(acct_prefix):].strip("\"") + elif line.startswith(pw_prefix): + assert password == None, "already found password" + password = line[len(pw_prefix):].strip("\"") + + assert username and password, "unable to find username and password for %s" % (UPLOAD_KEYCHAIN_ITEM) + return username, password + +def get_options(): + + identity = "" + username = "" + password = "@keychain:AC_PASSWORD" + out = os.path.join(os.getenv("HOME"), "Desktop") + + try: + opts, args = getopt.getopt(sys.argv[1:], "i:u:p:o:", ["identity=", "username=", "password=", "out="]) + except: + sys.stderr.write("error reading options\n") + + for opt, arg in opts: + if opt in ["-i", "--identity"]: + identity = arg + elif opt in ["-u", "--username"]: + username = arg + elif opt in ["-p", "--password"]: + password = arg + elif opt in ["-o", "--out"]: + out = arg + + return identity, username, password, out + +if __name__ == '__main__': + + identity, username, password, out = get_options() + + clean_and_build() + + if identity != "": + codesign(identity) + + new_version, new_version_string, minimum_system_version = read_versions() + + dmg_or_zip_path = create_zip_of_application(new_version_string) + + if username != "": + # will bail if any part fails + notarize_dmg_or_zip(dmg_or_zip_path, username, password) + + if dmg_or_zip_path.endswith("dmg"): + # xcrun stapler staple BibDesk.app-1.4.zip + x = Popen(["xcrun", "stapler", "staple", dmg_or_zip_path]) + rc = x.wait() + assert rc == 0, "stapler failed" + else: + # staple the application, then delete the zip we notarized + # and make a new zip of the stapled application, because stapler + # won't staple a damn zip file https://developer.apple.com/forums/thread/115670 + x = Popen(["xcrun", "stapler", "staple", BUILT_APP]) + rc = x.wait() + assert rc == 0, "stapler failed" + os.unlink(dmg_or_zip_path) + dmg_or_zip_path = create_zip_of_application(new_version_string) + + try: + # probably already exists + os.mkdirs(out) + except Exception as e: + assert os.path.isdir(out), "%s does not exist" % (out) + + write_appcast(new_version, new_version_string, minimum_system_version, dmg_or_zip_path, out) + + target_path = os.path.join(out, os.path.basename(dmg_or_zip_path)) + if (os.path.exists(target_path)): + os.unlink(target_path) + os.rename(dmg_or_zip_path, target_path) Property changes on: trunk/bibdesk/build_release.py ___________________________________________________________________ Added: svn:executable ## -0,0 +1 ## +* \ No newline at end of property This was sent by the SourceForge.net collaborative development platform, the world's largest Open Source development site. _______________________________________________ Bibdesk-commit mailing list Bibdesk-commit@lists.sourceforge.net https://lists.sourceforge.net/lists/listinfo/bibdesk-commit