#!/usr/bin/env bash # # KeePassXC Release Preparation Helper # Copyright (C) 2017 KeePassXC team # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 2 or (at your option) # version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . printf "\e[1m\e[32mKeePassXC\e[0m Release Preparation Helper\n" printf "Copyright (C) 2017 KeePassXC Team \n\n" # ----------------------------------------------------------------------- # global default values # ----------------------------------------------------------------------- RELEASE_NAME="" APP_NAME="KeePassXC" SRC_DIR="." GPG_KEY="CFB4C2166397D0D2" GPG_GIT_KEY="" OUTPUT_DIR="release" SOURCE_BRANCH="" TARGET_BRANCH="master" TAG_NAME="" DOCKER_IMAGE="" DOCKER_CONTAINER_NAME="keepassxc-build-container" CMAKE_GENERATOR="Ninja" CMAKE_OPTIONS="" CPACK_GENERATORS="WIX;ZIP" COMPILER="g++" MAKE_OPTIONS="-j$(getconf _NPROCESSORS_ONLN)" BUILD_PLUGINS="all" INSTALL_PREFIX="/usr/local" ORIG_BRANCH="" ORIG_CWD="$(pwd)" MACOSX_DEPLOYMENT_TARGET=10.13 GREP="grep" TIMESTAMP_SERVER="http://timestamp.sectigo.com" # ----------------------------------------------------------------------- # helper functions # ----------------------------------------------------------------------- printUsage() { local cmd if [ "" == "$1" ] || [ "help" == "$1" ]; then cmd="COMMAND" elif [ "check" == "$1" ] || [ "merge" == "$1" ] || [ "build" == "$1" ] \ || [ "gpgsign" == "$1" ] || [ "appsign" == "$1" ] || [ "notarize" == "$1" ] || [ "appimage" == "$1" ]; then cmd="$1" else logError "Unknown command: '$1'\n" cmd="COMMAND" fi printf "\e[1mUsage:\e[0m $(basename $0) $cmd [options]\n" if [ "COMMAND" == "$cmd" ]; then cat << EOF Commands: check Perform a dry-run check, nothing is changed merge Merge release branch into main branch and create release tags build Build and package binary release from sources gpgsign Sign previously compiled release packages with GPG appsign Sign binaries with code signing certificates on Windows and macOS notarize Submit macOS application DMG for notarization help Show help for the given command EOF elif [ "merge" == "$cmd" ]; then cat << EOF Merge release branch into main branch and create release tags Options: -v, --version Release version number or name (required) -a, --app-name Application name (default: '${APP_NAME}') -s, --source-dir Source directory (default: '${SRC_DIR}') -k, --key GPG key used to sign the merge commit and release tag, leave empty to let Git choose your default key (default: '${GPG_GIT_KEY}') -r, --release-branch Source release branch to merge from (default: 'release/VERSION') --target-branch Target branch to merge to (default: '${TARGET_BRANCH}') -t, --tag-name Override release tag name (defaults to version number) -h, --help Show this help EOF elif [ "build" == "$cmd" ]; then cat << EOF Build and package binary release from sources Options: -v, --version Release version number or name (required) -a, --app-name Application name (default: '${APP_NAME}') -s, --source-dir Source directory (default: '${SRC_DIR}') -o, --output-dir Output directory where to build the release (default: '${OUTPUT_DIR}') -t, --tag-name Release tag to check out (defaults to version number) -b, --build Build sources after exporting release -d, --docker-image Use the specified Docker image to compile the application. The image must have all required build dependencies installed. This option has no effect if --build is not set. --container-name Docker container name (default: '${DOCKER_CONTAINER_NAME}') The container must not exist already --snapcraft Create and use docker image to build snapcraft distribution. This option has no effect if --docker-image is not set. --appimage Build a Linux AppImage after compilation. If this option is set, --install-prefix has no effect --appsign Perform platform specific App Signing before packaging --timestamp Explicitly set the timestamp server to use for appsign (default: '${TIMESTAMP_SERVER}') --vcpkg Specify VCPKG toolchain file (example: ~/vcpkg/scripts/buildsystems/vcpkg.cmake) -k, --key Specify the App Signing Key/Identity --cmake-generator Override the default CMake generator (Default: Ninja) -c, --cmake-options Additional CMake options for compiling the sources --compiler Compiler to use (default: '${COMPILER}') -m, --make-options Make options for compiling sources (default: '${MAKE_OPTIONS}') -g, --generators Additional CPack generators (default: '${CPACK_GENERATORS}') -i, --install-prefix Install prefix (default: '${INSTALL_PREFIX}') -p, --plugins Space-separated list of plugins to build (default: ${BUILD_PLUGINS}) --snapshot Don't checkout the release tag -n, --no-source-tarball Don't build source tarball -h, --help Show this help EOF elif [ "gpgsign" == "$cmd" ]; then cat << EOF Sign previously compiled release packages with GPG Options: -f, --files Files to sign (required) -k, --key GPG key used to sign the files (default: '${GPG_KEY}') -h, --help Show this help EOF elif [ "appsign" == "$cmd" ]; then cat << EOF Sign binaries with code signing certificates on Windows and macOS Options: -f, --files Files to sign (required) -k, --key, -i, --identity Signing Key or Apple Developer ID (required) --timestamp Explicitly set the timestamp server to use for appsign (default: '${TIMESTAMP_SERVER}') -u, --username Apple username for notarization (required on macOS) -h, --help Show this help EOF elif [ "notarize" == "$cmd" ]; then cat << EOF Submit macOS application DMG for notarization Options: -f, --files Files to notarize (required) -u, --username Apple username for notarization (required) -c, --keychain Apple keychain entry name storing the notarization app password (default: 'AC_PASSWORD') -h, --help Show this help EOF elif [ "appimage" == "$cmd" ]; then cat << EOF Generate Linux AppImage from 'make install' AppDir Options: -a, --appdir Input AppDir (required) -v, --version KeePassXC version -o, --output-dir Output directory where to build the AppImage (default: '${OUTPUT_DIR}') -d, --docker-image Use the specified Docker image to build the AppImage. The image must have all required build dependencies installed. --container-name Docker container name (default: '${DOCKER_CONTAINER_NAME}') The container must not exist already --appsign Embed a PGP signature into the AppImage -k, --key The PGP Signing Key --verbosity linuxdeploy verbosity (default: 3) -h, --help Show this help EOF fi } logInfo() { printf "\e[1m[ \e[34mINFO\e[39m ]\e[0m $1\n" } logWarn() { printf "\e[1m[ \e[33mWARNING\e[39m ]\e[0m $1\n" } logError() { printf "\e[1m[ \e[31mERROR\e[39m ]\e[0m $1\n" >&2 } init() { if [ "" == "$RELEASE_NAME" ]; then logError "Missing arguments, --version is required!\n" printUsage "check" exit 1 fi if [ "" == "$TAG_NAME" ]; then TAG_NAME="$RELEASE_NAME" fi if [ "" == "$SOURCE_BRANCH" ]; then SOURCE_BRANCH="release/${RELEASE_NAME}" fi ORIG_CWD="$(pwd)" SRC_DIR="$(realpath "$SRC_DIR")" cd "$SRC_DIR" > /dev/null 2>&1 ORIG_BRANCH="$(git rev-parse --abbrev-ref HEAD 2> /dev/null)" cd "$ORIG_CWD" } cleanup() { logInfo "Checking out original branch..." if [ "" != "$ORIG_BRANCH" ]; then git checkout "$ORIG_BRANCH" > /dev/null 2>&1 fi logInfo "Leaving source directory..." cd "$ORIG_CWD" } exitError() { logError "$1" cleanup exit 1 } exitTrap() { exitError "Existing upon user request..." } cmdExists() { command -v "$1" &> /dev/null } checkGrepCompat() { if ! grep -qPzo test <(echo test) 2> /dev/null; then if [ -e /usr/local/opt/grep/libexec/gnubin/grep ]; then GREP="/usr/local/opt/grep/libexec/gnubin/grep" else exitError "Incompatible grep implementation! If on macOS, please run 'brew install grep'." fi fi } checkSourceDirExists() { if [ ! -d "$SRC_DIR" ]; then exitError "Source directory '${SRC_DIR}' does not exist!" fi } checkOutputDirDoesNotExist() { if [ -e "$OUTPUT_DIR" ]; then exitError "Output directory '$OUTPUT_DIR' already exists. Please choose a different location!" fi } checkGitRepository() { if [ ! -d .git ] || [ ! -f CHANGELOG.md ]; then exitError "Source directory is not a valid Git repository!" fi } checkReleaseDoesNotExist() { git tag | $GREP -q "^$TAG_NAME$" if [ $? -eq 0 ]; then exitError "Release '$RELEASE_NAME' (tag: '$TAG_NAME') already exists!" fi } checkWorkingTreeClean() { git diff-index --quiet HEAD -- if [ $? -ne 0 ]; then exitError "Current working tree is not clean! Please commit or unstage any changes." fi } checkSourceBranchExists() { git rev-parse "$SOURCE_BRANCH" > /dev/null 2>&1 if [ $? -ne 0 ]; then exitError "Source branch '$SOURCE_BRANCH' does not exist!" fi } checkTargetBranchExists() { git rev-parse "$TARGET_BRANCH" > /dev/null 2>&1 if [ $? -ne 0 ]; then exitError "Target branch '$TARGET_BRANCH' does not exist!" fi } checkVersionInCMake() { local app_name_upper="$(echo "$APP_NAME" | tr '[:lower:]' '[:upper:]')" local major_num="$(echo ${RELEASE_NAME} | cut -f1 -d.)" local minor_num="$(echo ${RELEASE_NAME} | cut -f2 -d.)" local patch_num="$(echo ${RELEASE_NAME} | cut -f3 -d. | cut -f1 -d-)" $GREP -q "${app_name_upper}_VERSION_MAJOR \"${major_num}\"" CMakeLists.txt if [ $? -ne 0 ]; then exitError "${app_name_upper}_VERSION_MAJOR not updated to '${major_num}' in CMakeLists.txt!" fi $GREP -q "${app_name_upper}_VERSION_MINOR \"${minor_num}\"" CMakeLists.txt if [ $? -ne 0 ]; then exitError "${app_name_upper}_VERSION_MINOR not updated to '${minor_num}' in CMakeLists.txt!" fi $GREP -q "${app_name_upper}_VERSION_PATCH \"${patch_num}\"" CMakeLists.txt if [ $? -ne 0 ]; then exitError "${app_name_upper}_VERSION_PATCH not updated to '${patch_num}' in CMakeLists.txt!" fi } checkChangeLog() { if [ ! -f CHANGELOG.md ]; then exitError "No CHANGELOG file found!" fi $GREP -qPzo "## ${RELEASE_NAME} \(\d{4}-\d{2}-\d{2}\)\n" CHANGELOG.md if [ $? -ne 0 ]; then exitError "'CHANGELOG.md' has not been updated to the '${RELEASE_NAME}' release!" fi } checkAppStreamInfo() { if [ ! -f share/linux/org.keepassxc.KeePassXC.appdata.xml ]; then exitError "No AppStream info file found!" fi $GREP -qPzo "" share/linux/org.keepassxc.KeePassXC.appdata.xml if [ $? -ne 0 ]; then exitError "'share/linux/org.keepassxc.KeePassXC.appdata.xml' has not been updated to the '${RELEASE_NAME}' release!" fi } checkSnapcraft() { if [ ! -f snap/snapcraft.yaml ]; then echo "Could not find snap/snapcraft.yaml!" return fi $GREP -qPzo "version: ${RELEASE_NAME}" snap/snapcraft.yaml if [ $? -ne 0 ]; then exitError "'snapcraft.yaml' has not been updated to the '${RELEASE_NAME}' release!" fi $GREP -qPzo "KEEPASSXC_BUILD_TYPE=Release" snap/snapcraft.yaml if [ $? -ne 0 ]; then exitError "'snapcraft.yaml' is not set for a release build!" fi } checkTransifexCommandExists() { if ! cmdExists tx; then exitError "Transifex tool 'tx' not installed! Please install it using 'pip install transifex-client'." fi } checkSigntoolCommandExists() { if ! cmdExists signtool; then exitError "signtool command not found on the PATH! Add the Windows SDK binary folder to your PATH." fi } checkXcodeSetup() { if ! cmdExists xcrun; then exitError "xcrun command not found on the PATH! Please check that you have correctly installed Xcode." fi if ! xcrun -f codesign > /dev/null 2>&1; then exitError "codesign command not found. You may need to run 'sudo xcode-select -r' to set up Xcode." fi if ! xcrun -f altool > /dev/null 2>&1; then exitError "altool command not found. You may need to run 'sudo xcode-select -r' to set up Xcode." fi if ! xcrun -f stapler > /dev/null 2>&1; then exitError "stapler command not found. You may need to run 'sudo xcode-select -r' to set up Xcode." fi } checkQt5LUpdateExists() { if cmdExists lupdate && ! $(lupdate -version | $GREP -q "lupdate version 5\."); then if ! cmdExists lupdate-qt5; then exitError "Qt Linguist tool (lupdate-qt5) is not installed! Please install using 'apt install qttools5-dev-tools'" fi fi } performChecks() { logInfo "Performing basic checks..." checkGrepCompat checkSourceDirExists logInfo "Changing to source directory..." cd "${SRC_DIR}" logInfo "Validating toolset and repository..." checkTransifexCommandExists checkQt5LUpdateExists checkGitRepository checkReleaseDoesNotExist checkWorkingTreeClean checkSourceBranchExists checkTargetBranchExists logInfo "Checking out '${SOURCE_BRANCH}'..." git checkout "$SOURCE_BRANCH" > /dev/null 2>&1 logInfo "Attempting to find '${RELEASE_NAME}' in various files..." checkVersionInCMake checkChangeLog checkAppStreamInfo checkSnapcraft logInfo "\e[1m\e[32mAll checks passed!\e[0m" } # re-implement realpath for OS X (thanks mschrag) # https://superuser.com/questions/205127/ if ! cmdExists realpath; then realpath() { pushd . > /dev/null if [ -d "$1" ]; then cd "$1" dirs -l +0 else cd "$(dirname "$1")" cur_dir=$(dirs -l +0) if [ "$cur_dir" == "/" ]; then echo "$cur_dir$(basename "$1")" else echo "$cur_dir/$(basename "$1")" fi fi popd > /dev/null } fi trap exitTrap SIGINT SIGTERM # ----------------------------------------------------------------------- # check command # ----------------------------------------------------------------------- check() { while [ $# -ge 1 ]; do local arg="$1" case "$arg" in -v|--version) RELEASE_NAME="$2" shift ;; esac shift done init performChecks cleanup logInfo "Congrats! You can successfully merge, build, and sign KeepassXC." } # ----------------------------------------------------------------------- # merge command # ----------------------------------------------------------------------- merge() { while [ $# -ge 1 ]; do local arg="$1" case "$arg" in -v|--version) RELEASE_NAME="$2" shift ;; -a|--app-name) APP_NAME="$2" shift ;; -s|--source-dir) SRC_DIR="$2" shift ;; -k|--key|-g|--gpg-key) GPG_GIT_KEY="$2" shift ;; --timestamp) TIMESTAMP_SERVER="$2" shift ;; -r|--release-branch) SOURCE_BRANCH="$2" shift ;; --target-branch) TARGET_BRANCH="$2" shift ;; -t|--tag-name) TAG_NAME="$2" shift ;; -h|--help) printUsage "merge" exit ;; *) logError "Unknown option '$arg'\n" printUsage "merge" exit 1 ;; esac shift done init performChecks logInfo "Updating language files..." ./share/translations/update.sh update ./share/translations/update.sh pull if [ 0 -ne $? ]; then exitError "Updating translations failed!" fi git diff-index --quiet HEAD -- if [ $? -ne 0 ]; then git add -A ./share/translations/ logInfo "Committing changes..." if [ "" == "$GPG_GIT_KEY" ]; then git commit -m "Update translations" else git commit -m "Update translations" -S"$GPG_GIT_KEY" fi fi CHANGELOG=$($GREP -Pzo "(?<=${RELEASE_NAME} \(\d{4}-\d{2}-\d{2}\)\n\n)\n?(?:.|\n)+?\n(?=## )" CHANGELOG.md \ | sed 's/^### //' | tr -d \\0) COMMIT_MSG="Release ${RELEASE_NAME}" logInfo "Checking out target branch '${TARGET_BRANCH}'..." git checkout "$TARGET_BRANCH" > /dev/null 2>&1 logInfo "Merging '${SOURCE_BRANCH}' into '${TARGET_BRANCH}'..." git merge "$SOURCE_BRANCH" --no-ff -m "$COMMIT_MSG" -m "${CHANGELOG}" "$SOURCE_BRANCH" -S"$GPG_GIT_KEY" logInfo "Creating tag '${TAG_NAME}'..." if [ "" == "$GPG_GIT_KEY" ]; then git tag -a "$TAG_NAME" -m "$COMMIT_MSG" -m "${CHANGELOG}" -s else git tag -a "$TAG_NAME" -m "$COMMIT_MSG" -m "${CHANGELOG}" -s -u "$GPG_GIT_KEY" fi cleanup logInfo "All done!" logInfo "Please merge the release branch back into the develop branch now and then push your changes." logInfo "Don't forget to also push the tags using \e[1mgit push --tags\e[0m." } # ----------------------------------------------------------------------- # appimage command # ----------------------------------------------------------------------- appimage() { local appdir local build_appsign=false local build_key local verbosity="1" while [ $# -ge 1 ]; do local arg="$1" case "$arg" in -v|--version) RELEASE_NAME="$2" shift ;; -a|--appdir) appdir="$2" shift ;; -o|--output-dir) OUTPUT_DIR="$2" shift ;; -d|--docker-image) DOCKER_IMAGE="$2" shift ;; --container-name) DOCKER_CONTAINER_NAME="$2" shift ;; --appsign) build_appsign=true ;; --verbosity) verbosity=$2 shift ;; -k|--key) build_key="$2" shift ;; -h|--help) printUsage "appimage" exit ;; *) logError "Unknown option '$arg'\n" printUsage "appimage" exit 1 ;; esac shift done if [ -z "${appdir}" ]; then logError "Missing arguments, --appdir is required!\n" printUsage "appimage" exit 1 fi if [ ! -d "${appdir}" ]; then exitError "AppDir does not exist, please create one with 'make install'!" elif [ -e "${appdir}/AppRun" ]; then exitError "AppDir has already been run through linuxdeploy, please create a fresh AppDir with 'make install'." fi appdir="$(realpath "$appdir")" local out="${OUTPUT_DIR}" if [ "" == "$out" ]; then out="." fi mkdir -p "$out" local out_real="$(realpath "$out")" cd "$out" local linuxdeploy="linuxdeploy" local linuxdeploy_cleanup local linuxdeploy_plugin_qt="linuxdeploy-plugin-qt" local linuxdeploy_plugin_qt_cleanup local appimagetool="appimagetool" local appimagetool_cleanup logInfo "Testing for AppImage tools..." local docker_test_cmd if [ "" != "$DOCKER_IMAGE" ]; then docker_test_cmd="docker run --rm ${DOCKER_IMAGE}" fi # Test if linuxdeploy and linuxdeploy-plugin-qt are installed # on the system or inside the Docker container if ! ${docker_test_cmd} which ${linuxdeploy} &> /dev/null; then logInfo "Downloading linuxdeploy..." linuxdeploy="./linuxdeploy" linuxdeploy_cleanup="rm -f ${linuxdeploy}" if ! curl -Lf "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" > "$linuxdeploy"; then exitError "linuxdeploy download failed." fi chmod +x "$linuxdeploy" fi if ! ${docker_test_cmd} which ${linuxdeploy_plugin_qt} &> /dev/null; then logInfo "Downloading linuxdeploy-plugin-qt..." linuxdeploy_plugin_qt="./linuxdeploy-plugin-qt" linuxdeploy_plugin_qt_cleanup="rm -f ${linuxdeploy_plugin_qt}" if ! curl -Lf "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage" > "$linuxdeploy_plugin_qt"; then exitError "linuxdeploy-plugin-qt download failed." fi chmod +x "$linuxdeploy_plugin_qt" fi # appimagetool is always run outside a Docker container, so we can access our GPG keys if ! cmdExists ${appimagetool}; then logInfo "Downloading appimagetool..." appimagetool="./appimagetool" appimagetool_cleanup="rm -f ${appimagetool}" if ! curl -Lf "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" > "$appimagetool"; then exitError "appimagetool download failed." fi chmod +x "$appimagetool" fi # Create custom AppRun wrapper cat << EOF > "${out_real}/KeePassXC-AppRun" #!/usr/bin/env bash export PATH="\$(dirname \$0)/usr/bin:\${PATH}" export LD_LIBRARY_PATH="\$(dirname \$0)/usr/lib:\${LD_LIBRARY_PATH}" if [ "\${1}" == "cli" ]; then shift exec keepassxc-cli "\$@" elif [ "\${1}" == "proxy" ]; then shift exec keepassxc-proxy "\$@" elif [ -v CHROME_WRAPPER ] || [ -v MOZ_LAUNCHED_CHILD ]; then exec keepassxc-proxy "\$@" else exec keepassxc "\$@" fi EOF chmod +x "${out_real}/KeePassXC-AppRun" # Find .desktop files, icons, and binaries to deploy local desktop_file="$(find "$appdir" -name "org.keepassxc.KeePassXC.desktop" | head -n1)" local icon="$(find "$appdir" -name 'keepassxc.png' | $GREP -P 'application/256x256/apps/keepassxc.png$' | head -n1)" local executables="$(IFS=$'\n' find "$appdir" | $GREP -P '/usr/bin/keepassxc[^/]*$' | xargs -i printf " --executable={}")" logInfo "Collecting libs and patching binaries..." if [ "" == "$DOCKER_IMAGE" ]; then "$linuxdeploy" --verbosity=${verbosity} --plugin=qt --appdir="$appdir" --desktop-file="$desktop_file" \ --custom-apprun="${out_real}/KeePassXC-AppRun" --icon-file="$icon" ${executables} \ --library=$(ldconfig -p | $GREP x86-64 | $GREP -oP '/[^\s]+/libgpg-error\.so\.\d+$' | head -n1) else desktop_file="${desktop_file//${appdir}/\/keepassxc\/AppDir}" icon="${icon//${appdir}/\/keepassxc\/AppDir}" executables="${executables//${appdir}/\/keepassxc\/AppDir}" docker run --name "$DOCKER_CONTAINER_NAME" --rm \ --cap-add SYS_ADMIN --security-opt apparmor:unconfined --device /dev/fuse \ -v "${appdir}:/keepassxc/AppDir:rw" \ -v "${out_real}:/keepassxc/out:rw" \ "$DOCKER_IMAGE" \ bash -c "cd /keepassxc/out && ${linuxdeploy} --verbosity=${verbosity} --plugin=qt --appdir=/keepassxc/AppDir \ --custom-apprun="/keepassxc/out/KeePassXC-AppRun" --desktop-file=${desktop_file} --icon-file=${icon} ${executables} \ --library=\$(ldconfig -p | grep x86-64 | grep -oP '/[^\s]+/libgpg-error\.so\.\d+$' | head -n1)" fi if [ $? -ne 0 ]; then exitError "AppDir deployment failed." fi logInfo "Creating AppImage..." local appsign_flag="" local appsign_key_flag="" if ${build_appsign}; then appsign_flag="--sign" appsign_key_flag="--sign-key ${build_key}" fi local appimage_name="KeePassXC-x86_64.AppImage" if [ "" != "$RELEASE_NAME" ]; then appimage_name="KeePassXC-${RELEASE_NAME}-x86_64.AppImage" echo "X-AppImage-Version=${RELEASE_NAME}" >> "$desktop_file" fi # Run appimagetool to package (and possibly sign) AppImage # --no-appstream is required, since it may crash on newer systems # see: https://github.com/AppImage/AppImageKit/issues/856 if ! "$appimagetool" --updateinformation "gh-releases-zsync|keepassxreboot|keepassxc|latest|KeePassXC-*-x86_64.AppImage.zsync" \ ${appsign_flag} ${appsign_key_flag} --no-appstream "$appdir" "${out_real}/${appimage_name}"; then exitError "AppImage creation failed." fi logInfo "Cleaning up temporary files..." ${linuxdeploy_cleanup} ${linuxdeploy_plugin_qt_cleanup} ${appimagetool_cleanup} rm -f "${out_real}/KeePassXC-AppRun" } # ----------------------------------------------------------------------- # build command # ----------------------------------------------------------------------- build() { local build_source_tarball=true local build_snapshot=false local build_snapcraft=false local build_appimage=false local build_generators="" local build_appsign=false local build_key="" local build_vcpkg="" while [ $# -ge 1 ]; do local arg="$1" case "$arg" in -v|--version) RELEASE_NAME="$2" shift ;; -a|--app-name) APP_NAME="$2" shift ;; -s|--source-dir) SRC_DIR="$2" shift ;; -o|--output-dir) OUTPUT_DIR="$2" shift ;; -t|--tag-name) TAG_NAME="$2" shift ;; -d|--docker-image) DOCKER_IMAGE="$2" shift ;; --container-name) DOCKER_CONTAINER_NAME="$2" shift ;; --appsign) build_appsign=true ;; --timestamp) TIMESTAMP_SERVER="$2" shift ;; -k|--key) build_key="$2" shift ;; --snapcraft) build_snapcraft=true ;; --appimage) build_appimage=true ;; --cmake-generator) CMAKE_GENERATOR="$2" shift ;; -c|--cmake-options) CMAKE_OPTIONS="$2" shift ;; --compiler) COMPILER="$2" shift ;; --vcpkg) build_vcpkg="$2" shift ;; -m|--make-options) MAKE_OPTIONS="$2" shift ;; -g|--generators) build_generators="$2" shift ;; -i|--install-prefix) INSTALL_PREFIX="$2" shift ;; -p|--plugins) BUILD_PLUGINS="$2" shift ;; -n|--no-source-tarball) build_source_tarball=false ;; --snapshot) build_snapshot=true ;; -h|--help) printUsage "build" exit ;; *) logError "Unknown option '$arg'\n" printUsage "build" exit 1 ;; esac shift done init OUTPUT_DIR="$(realpath "$OUTPUT_DIR")" # Resolve appsign key to absolute path if under Windows if [[ "${build_key}" && "$(uname -o)" == "Msys" ]]; then build_key="$(realpath "${build_key}")" fi if [[ -f ${build_vcpkg} ]]; then CMAKE_OPTIONS="${CMAKE_OPTIONS} -DCMAKE_TOOLCHAIN_FILE=${build_vcpkg} -DX_VCPKG_APPLOCAL_DEPS_INSTALL=ON" fi if ${build_snapshot}; then TAG_NAME="HEAD" local branch=`git rev-parse --abbrev-ref HEAD` logInfo "Using current branch ${branch} to build..." RELEASE_NAME="${RELEASE_NAME}-snapshot" CMAKE_OPTIONS="${CMAKE_OPTIONS} -DKEEPASSXC_BUILD_TYPE=Snapshot -DOVERRIDE_VERSION=${RELEASE_NAME}" else checkGrepCompat checkWorkingTreeClean if $(echo "$TAG_NAME" | $GREP -qP "\-(alpha|beta)\\d+\$"); then CMAKE_OPTIONS="${CMAKE_OPTIONS} -DKEEPASSXC_BUILD_TYPE=PreRelease" logInfo "Checking out pre-release tag '${TAG_NAME}'..." else CMAKE_OPTIONS="${CMAKE_OPTIONS} -DKEEPASSXC_BUILD_TYPE=Release" logInfo "Checking out release tag '${TAG_NAME}'..." fi git checkout "$TAG_NAME" > /dev/null 2>&1 fi logInfo "Creating output directory..." mkdir -p "$OUTPUT_DIR" if [ $? -ne 0 ]; then exitError "Failed to create output directory!" fi if ${build_source_tarball}; then logInfo "Creating source tarball..." local app_name_lower="$(echo "$APP_NAME" | tr '[:upper:]' '[:lower:]')" local prefix="${app_name_lower}-${RELEASE_NAME}" local tarball_name="${prefix}-src.tar" git archive --format=tar "$TAG_NAME" --prefix="${prefix}/" --output="${OUTPUT_DIR}/${tarball_name}" # add .version and .gitrev files to tarball mkdir "${prefix}" echo -n ${RELEASE_NAME} > "${prefix}/.version" echo -n `git rev-parse --short=7 HEAD` > "${prefix}/.gitrev" tar --append --file="${OUTPUT_DIR}/${tarball_name}" "${prefix}/.version" "${prefix}/.gitrev" rm "${prefix}/.version" "${prefix}/.gitrev" rmdir "${prefix}" 2> /dev/null local xz="xz" if ! cmdExists xz; then logWarn "xz not installed. Falling back to bz2..." xz="bzip2" fi $xz -6 "${OUTPUT_DIR}/${tarball_name}" fi if ! ${build_snapshot} && [ -e "${OUTPUT_DIR}/build-release" ]; then logInfo "Cleaning existing build directory..." rm -rf "${OUTPUT_DIR}/build-release" 2> /dev/null if [ $? -ne 0 ]; then exitError "Failed to clean existing build directory, please do it manually." fi fi logInfo "Creating build directory..." mkdir -p "${OUTPUT_DIR}/build-release" cd "${OUTPUT_DIR}/build-release" logInfo "Configuring sources..." for p in ${BUILD_PLUGINS}; do CMAKE_OPTIONS="${CMAKE_OPTIONS} -DWITH_XC_$(echo $p | tr '[:lower:]' '[:upper:]')=On" done if [ "$(uname -o 2> /dev/null)" == "GNU/Linux" ] && ${build_appimage}; then CMAKE_OPTIONS="${CMAKE_OPTIONS} -DKEEPASSXC_DIST_TYPE=AppImage" # linuxdeploy requires /usr as install prefix INSTALL_PREFIX="/usr" fi # Do not build tests cases CMAKE_OPTIONS="${CMAKE_OPTIONS} -DWITH_TESTS=OFF" if [ "$COMPILER" == "g++" ]; then export CC=gcc elif [ "$COMPILER" == "clang++" ]; then export CC=clang else export CC="$COMPILER" fi export CXX="$COMPILER" if [ "" == "$DOCKER_IMAGE" ]; then if [ "$(uname -s)" == "Darwin" ]; then # Building on macOS export MACOSX_DEPLOYMENT_TARGET logInfo "Configuring build..." cmake -G "${CMAKE_GENERATOR}" -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES="$(uname -m)" \ -DCMAKE_INSTALL_PREFIX="${INSTALL_PREFIX}" ${CMAKE_OPTIONS} "$SRC_DIR" logInfo "Compiling and packaging sources..." cmake --build . -- ${MAKE_OPTIONS} cpack -G "DragNDrop" # Appsign the executables if desired if ${build_appsign}; then logInfo "Signing executable files" appsign "-f" "./${APP_NAME}-${RELEASE_NAME}.dmg" "-k" "${build_key}" fi mv "./${APP_NAME}-${RELEASE_NAME}.dmg" "../${APP_NAME}-${RELEASE_NAME}-$(uname -m).dmg" elif [ "$(uname -o)" == "Msys" ]; then # Building on Windows with Msys2 logInfo "Configuring build..." cmake -DCMAKE_BUILD_TYPE=Release -G "${CMAKE_GENERATOR}" -DCMAKE_INSTALL_PREFIX="${INSTALL_PREFIX}" \ ${CMAKE_OPTIONS} "$SRC_DIR" logInfo "Compiling and packaging sources..." cmake --build . --config "Release" -- ${MAKE_OPTIONS} # Appsign the executables if desired if ${build_appsign} && [ -f "${build_key}" ]; then logInfo "Signing executable files" appsign "-f" $(find src | $GREP -Pi 'keepassxc.*(.exe$|.dll$)') "-k" "${build_key}" fi # Call cpack directly instead of calling make package. # This is important because we want to build the MSI when making a # release. cpack -G "${CPACK_GENERATORS};${build_generators}" # Inject the portable config into the zip build and rename touch .portable for filename in ${APP_NAME}-*.zip; do logInfo "Creating portable zip file" local folder=$(echo ${filename} | sed -r 's/(.*)\.zip/\1/') python -c 'import zipfile,sys ; zipfile.ZipFile(sys.argv[1],"a").write(sys.argv[2],sys.argv[3])' \ ${filename} .portable ${folder}/.portable mv ${filename} ${folder}-portable.zip done rm .portable mv "${APP_NAME}-"*.* ../ else mkdir -p "${OUTPUT_DIR}/KeePassXC.AppDir" # Building on Linux without Docker container logInfo "Configuring build..." cmake -DCMAKE_BUILD_TYPE=Release ${CMAKE_OPTIONS} \ -DCMAKE_INSTALL_PREFIX="${INSTALL_PREFIX}" "$SRC_DIR" logInfo "Compiling sources..." make ${MAKE_OPTIONS} logInfo "Installing to bin dir..." make DESTDIR="${OUTPUT_DIR}/KeePassXC.AppDir" install/strip fi else if ${build_snapcraft}; then logInfo "Building snapcraft docker image..." sudo docker image build -t "$DOCKER_IMAGE" "$(realpath "$SRC_DIR")/ci/snapcraft" logInfo "Launching Docker contain to compile snapcraft..." sudo docker run --name "$DOCKER_CONTAINER_NAME" --rm \ -v "$(realpath "$SRC_DIR"):/keepassxc" -w "/keepassxc" \ "$DOCKER_IMAGE" snapcraft else mkdir -p "${OUTPUT_DIR}/KeePassXC.AppDir" logInfo "Launching Docker container to compile sources..." docker run --name "$DOCKER_CONTAINER_NAME" --rm \ --cap-add SYS_ADMIN --security-opt apparmor:unconfined --device /dev/fuse \ -e "CC=${CC}" -e "CXX=${CXX}" \ -v "$(realpath "$SRC_DIR"):/keepassxc/src:ro" \ -v "$(realpath "$OUTPUT_DIR"):/keepassxc/out:rw" \ "$DOCKER_IMAGE" \ bash -c "cd /keepassxc/out/build-release && \ cmake -DCMAKE_BUILD_TYPE=Release ${CMAKE_OPTIONS} \ -DCMAKE_INSTALL_PREFIX=${INSTALL_PREFIX} /keepassxc/src && \ make ${MAKE_OPTIONS} && make DESTDIR=/keepassxc/out/KeePassXC.AppDir install/strip" fi if [ 0 -ne $? ]; then exitError "Docker build failed!" fi logInfo "Build finished, Docker container terminated." fi if [ "$(uname -o 2> /dev/null)" == "GNU/Linux" ] && ${build_appimage}; then local appsign_flag="" local appsign_key_flag="" local docker_image_flag="" local docker_container_name_flag="" if ${build_appsign}; then appsign_flag="--appsign" appsign_key_flag="-k ${build_key}" fi if [ "" != "${DOCKER_IMAGE}" ]; then docker_image_flag="-d ${DOCKER_IMAGE}" docker_container_name_flag="--container-name ${DOCKER_CONTAINER_NAME}" fi appimage "-a" "${OUTPUT_DIR}/KeePassXC.AppDir" "-o" "${OUTPUT_DIR}" \ ${appsign_flag} ${appsign_key_flag} ${docker_image_flag} ${docker_container_name_flag} fi cleanup logInfo "All done!" } # ----------------------------------------------------------------------- # gpgsign command # ----------------------------------------------------------------------- gpgsign() { local sign_files=() while [ $# -ge 1 ]; do local arg="$1" case "$arg" in -f|--files) while [ "${2:0:1}" != "-" ] && [ $# -ge 2 ]; do sign_files+=("$2") shift done ;; -k|--key|-g|--gpg-key) GPG_KEY="$2" shift ;; -h|--help) printUsage "gpgsign" exit ;; *) logError "Unknown option '$arg'\n" printUsage "gpgsign" exit 1 ;; esac shift done if [ -z "${sign_files}" ]; then logError "Missing arguments, --files is required!\n" printUsage "gpgsign" exit 1 fi for f in "${sign_files[@]}"; do if [ ! -f "$f" ]; then exitError "File '${f}' does not exist or is not a file!" fi logInfo "Signing file '${f}' using release key..." gpg --output "${f}.sig" --armor --local-user "$GPG_KEY" --detach-sig "$f" if [ 0 -ne $? ]; then exitError "Signing failed!" fi logInfo "Creating digest for file '${f}'..." local rp="$(realpath "$f")" local bname="$(basename "$f")" (cd "$(dirname "$rp")"; sha256sum "$bname" > "${bname}.DIGEST") done logInfo "All done!" } # ----------------------------------------------------------------------- # appsign command # ----------------------------------------------------------------------- appsign() { local sign_files=() local key while [ $# -ge 1 ]; do local arg="$1" case "$arg" in -f|--files) while [ "${2:0:1}" != "-" ] && [ $# -ge 2 ]; do sign_files+=("$2") shift done ;; -k|--key|-i|--identity) key="$2" shift ;; -h|--help) printUsage "appsign" exit ;; *) logError "Unknown option '$arg'\n" printUsage "appsign" exit 1 ;; esac shift done if [ -z "${key}" ]; then logError "Missing arguments, --key is required!\n" printUsage "appsign" exit 1 fi if [ -z "${sign_files}" ]; then logError "Missing arguments, --files is required!\n" printUsage "appsign" exit 1 fi for f in "${sign_files[@]}"; do if [ ! -e "${f}" ]; then exitError "File '${f}' does not exist!" fi done if [ "$(uname -s)" == "Darwin" ]; then checkXcodeSetup checkGrepCompat local orig_dir="$(pwd)" local real_src_dir="$(realpath "${SRC_DIR}")" for f in "${sign_files[@]}"; do if [[ ${f: -4} == '.dmg' ]]; then logInfo "Unpacking disk image '${f}'..." local tmp_dir="/tmp/KeePassXC_${RANDOM}" mkdir -p ${tmp_dir}/mnt if ! hdiutil attach -quiet -noautoopen -mountpoint ${tmp_dir}/mnt "${f}"; then exitError "DMG mount failed!" fi cd ${tmp_dir} cp -a ./mnt ./app hdiutil detach -quiet ${tmp_dir}/mnt local app_dir_tmp="./app/KeePassXC.app" if [ ! -d "$app_dir_tmp" ]; then cd "${orig_dir}" exitError "Unpacking failed!" fi elif [[ ${f: -4} == '.app' ]]; then local app_dir_tmp="$f" else logWarn "Skipping non-app file '${f}'..." continue fi logInfo "Signing libraries and frameworks..." if ! find "$app_dir_tmp" \( -name '*.dylib' -o -name '*.so' -o -name '*.framework' \) -print0 | xargs -0 \ xcrun codesign --sign "${key}" --verbose --force --options runtime; then cd "${orig_dir}" exitError "Signing failed!" fi logInfo "Signing executables..." if ! find "${app_dir_tmp}/Contents/MacOS" \( -type f -not -name KeePassXC \) -print0 | xargs -0 \ xcrun codesign --sign "${key}" --verbose --force --options runtime; then cd "${orig_dir}" exitError "Signing failed!" fi # Sign main executable with additional entitlements if ! xcrun codesign --sign "${key}" --verbose --force --options runtime --entitlements \ "${real_src_dir}/share/macosx/keepassxc.entitlements" "${app_dir_tmp}/Contents/MacOS/KeePassXC"; then cd "${orig_dir}" exitError "Signing failed!" fi if [[ ${f: -4} == '.dmg' ]]; then logInfo "Repacking disk image..." hdiutil create \ -volname "KeePassXC" \ -size $((1000 * ($(du -sk ./app | cut -f1) + 5000))) \ -srcfolder ./app \ -fs HFS+ \ -fsargs "-c c=64,a=16,e=16" \ -format UDBZ \ "${tmp_dir}/$(basename "${f}")" cd "${orig_dir}" cp -f "${tmp_dir}/$(basename "${f}")" "${f}" rm -Rf ${tmp_dir} fi logInfo "File '${f}' successfully signed." done elif [ "$(uname -o)" == "Msys" ]; then if [[ ! -f "${key}" ]]; then exitError "Appsign key file was not found! (${key})" fi logInfo "Using appsign key ${key}." IFS=$'\n' read -s -r -p "Key password: " password echo for f in "${sign_files[@]}"; do ext=${f: -4} if [[ $ext == ".msi" || $ext == ".exe" || $ext == ".dll" ]]; then # Make sure we can find the signtool checkSigntoolCommandExists # osslsigncode does not succeed at signing MSI files at this time... logInfo "Signing file '${f}' using Microsoft signtool..." signtool sign -f "${key}" -p "${password}" -d "KeePassXC" -td sha256 \ -fd sha256 -tr "${TIMESTAMP_SERVER}" "${f}" if [ 0 -ne $? ]; then exitError "Signing failed!" fi else logInfo "Skipping non-executable file '${f}'..." fi done else exitError "Unsupported platform for code signing!\n" fi logInfo "All done!" } # ----------------------------------------------------------------------- # notarize command # ----------------------------------------------------------------------- notarize() { local notarize_files=() local ac_username local ac_keychain="AC_PASSWORD" while [ $# -ge 1 ]; do local arg="$1" case "$arg" in -f|--files) while [ "${2:0:1}" != "-" ] && [ $# -ge 2 ]; do notarize_files+=("$2") shift done ;; -u|--username) ac_username="$2" shift ;; -c|--keychain) ac_keychain="$2" shift ;; -h|--help) printUsage "notarize" exit ;; *) logError "Unknown option '$arg'\n" printUsage "notarize" exit 1 ;; esac shift done if [ "$(uname -s)" != "Darwin" ]; then exitError "Notarization is only supported on macOS!" fi if [ -z "${notarize_files}" ]; then logError "Missing arguments, --files is required!\n" printUsage "notarize" exit 1 fi if [ "$ac_username" == "" ]; then logError "Missing arguments, --username is required!" printUsage "notarize" exit 1 fi for f in "${notarize_files[@]}"; do if [[ ${f: -4} != '.dmg' ]]; then logWarn "Skipping non-DMG file '${f}'..." continue fi logInfo "Submitting disk image '${f}' for notarization..." local status status="$(xcrun altool --notarize-app \ --primary-bundle-id "org.keepassxc.keepassxc" \ --username "${ac_username}" \ --password "@keychain:${ac_keychain}" \ --file "${f}" 2> /dev/null)" if [ 0 -ne $? ]; then logError "Submission failed!" exitError "Error message:\n${status}" fi local ticket="$(echo "${status}" | $GREP -oP "[a-f0-9-]+$")" logInfo "Submission successful. Ticket ID: ${ticket}." logInfo "Waiting for notarization to finish (this may take a while)..." while true; do echo -n "." status="$(xcrun altool --notarization-info "${ticket}" \ --username "${ac_username}" \ --password "@keychain:${ac_keychain}" 2> /dev/null)" if echo "$status" | $GREP -q "Status Code: 0"; then logInfo "\nNotarization successful." break elif echo "$status" | $GREP -q "Status Code"; then logError "\nNotarization failed!" exitError "Error message:\n${status}" fi sleep 5 done logInfo "Stapling ticket to disk image..." xcrun stapler staple "${f}" if [ 0 -ne $? ]; then exitError "Stapling failed!" fi logInfo "Disk image successfully notarized." done } # ----------------------------------------------------------------------- # parse global command line # ----------------------------------------------------------------------- MODE="$1" shift if [ "" == "$MODE" ]; then logError "Missing arguments!\n" printUsage exit 1 elif [ "help" == "$MODE" ]; then printUsage "$1" exit elif [ "check" == "$MODE" ] || [ "merge" == "$MODE" ] || [ "build" == "$MODE" ] \ || [ "gpgsign" == "$MODE" ] || [ "appsign" == "$MODE" ]|| [ "notarize" == "$MODE" ] \ || [ "appimage" == "$MODE" ]; then ${MODE} "$@" else printUsage "$MODE" fi