chore(modules): update submodule reference #178
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Copyright (C) 2021-2026 Saturneric <eric@bktus.com> | |
| # | |
| # This file is part of GpgFrontend. | |
| # | |
| # GpgFrontend 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 3 of the License, or | |
| # (at your option) any later version. | |
| # | |
| # GpgFrontend 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 GpgFrontend. If not, see <https://www.gnu.org/licenses/>. | |
| # | |
| # The initial version of the source code is inherited from | |
| # the gpg4usb project, which is under GPL-3.0-or-later. | |
| # | |
| # All the source code of GpgFrontend was modified and released by | |
| # Saturneric <eric@bktus.com> starting on May 12, 2021. | |
| # | |
| # SPDX-License-Identifier: GPL-3.0-or-later | |
| # | |
| # macOS trust boundary | |
| # -------------------- | |
| # The macOS pipeline is split in two on purpose. `build-macos` compiles and | |
| # deploys the bundle and never sees a secret; `sign-macos` holds the Apple | |
| # credentials and runs nothing but Apple's own tools on the finished bundle. | |
| # That keeps the Developer ID private key off any runner that also executes | |
| # build-time code -- submodules, cargo build scripts, brew formulas and | |
| # third-party build actions. | |
| # | |
| # `build-macos` is treated as potentially compromised, so everything it hands | |
| # over is untrusted bytes. `sign-macos` may unpack, inspect and sign that | |
| # payload; it must never source, evaluate, execute, or take configuration or | |
| # signing policy from it. Signing identity, entitlement policy, signing order | |
| # and acceptance checks all live in this file, not in the artifact. | |
| # | |
| # What this does NOT do is establish provenance: a compromised build job can | |
| # still present a malicious payload that the signing job faithfully signs. | |
| # Isolating the key is the goal here; attestation is a separate problem. | |
| # | |
| # `sign-macos` reads these from the `macos-signing` GitHub environment: | |
| # DEVELOP_ID_CERT base64 of the Developer ID .p12 | |
| # DEVELOP_ID_CERT_PWD its export password | |
| # DEVELOPER_ID_CODE_SIGN_IDENTITY the identity string codesign selects | |
| # ASC_API_KEY_P8 contents of the App Store Connect .p8 | |
| # ASC_KEY_ID / ASC_ISSUER_ID its key id and issuer uuid | |
| # Once this flow is verified, these repository secrets can be deleted: | |
| # APPLE_DEVELOPER_ID, APPLE_DEVELOPER_TEAM_ID, APPLE_DEVELOPER_ID_SECRET | |
| # -- replaced by the App Store Connect API key above; | |
| # DEVELOPER_ID_PROVISIONING_PROFILE_DATA, DEVELOPER_ID_PROVISIONING_PROFILE_UUID | |
| # -- the Developer ID entitlements are hardened-runtime exceptions only and | |
| # need no provisioning profile. | |
| # GPGFRONTEND_XCODE_TEAM_ID and the MAS_* secrets stay: mas-sandbox.yml uses them. | |
| name: Build | |
| on: | |
| push: | |
| branches: | |
| - main | |
| tags: | |
| - "v*" | |
| paths-ignore: | |
| - "resource/lfs/locale/**" | |
| - "**.md" | |
| pull_request: | |
| branches: | |
| - main | |
| paths-ignore: | |
| - "resource/lfs/locale/**" | |
| - "**.md" | |
| workflow_dispatch: | |
| inputs: | |
| build_mode: | |
| description: "Build mode" | |
| required: true | |
| default: "nightly" | |
| type: choice | |
| options: | |
| - nightly | |
| - release | |
| # Supersede in-flight work: a new commit on a ref makes the run already going | |
| # for that ref obsolete. Grouping by ref keeps each PR, main, and each v* tag | |
| # in its own lane, so a tag build is never cancelled by unrelated activity. | |
| # Cancelling a run mid-signing is safe: the sign-macos cleanup step is | |
| # `if: always()`, which still fires on cancellation, so the temporary keychain | |
| # and the Apple credentials are removed either way. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| # Least privilege by default; only the release job is granted contents: write. | |
| permissions: | |
| contents: read | |
| env: | |
| BUILD_TYPE: RelWithDebInfo | |
| GNUPG_VERSION: "2.5.21" | |
| jobs: | |
| build: | |
| strategy: | |
| matrix: | |
| # macOS is built and signed by the separate build-macos / sign-macos | |
| # jobs below, so that the Developer ID key never shares a runner with a | |
| # compiler, a package manager or a third-party build action. | |
| os: ["ubuntu-22.04", "ubuntu-24.04-arm", "windows-2022"] | |
| # Portable vs installed is a compile-time decision | |
| # (GPGFRONTEND_BUILD_PORTABLE decides where the profile, and with it the | |
| # user's keys, lives), so each flavour needs its own configure + build. | |
| # They run as separate matrix jobs on purpose: the generated build | |
| # headers land in the source tree, so two flavours cannot share one | |
| # checkout. | |
| flavor: ["installed", "portable"] | |
| runs-on: ${{ matrix.os }} | |
| # No continue-on-error. | |
| # | |
| # It let a leg fail while the release still assembled, which meant `needs:` | |
| # could not notice a missing platform -- so completeness had to be proven | |
| # afterwards, by a build-record set checked against a second declaration | |
| # of this very matrix, and 24 tests guarding that check. Job dependencies | |
| # already express "all of these | |
| # must succeed"; reimplementing it was the expensive way to get a weaker | |
| # version of it. A failing leg now blocks the release, which is what a | |
| # release missing a platform should do. | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Set git to use LF line endings (Windows) | |
| run: | | |
| git config --global core.autocrlf false | |
| git config --global core.eol lf | |
| if: runner.os == 'Windows' | |
| - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 | |
| with: | |
| lfs: "false" | |
| submodules: recursive | |
| - name: Setup Build Mode | |
| shell: bash | |
| env: | |
| # Read through an env var rather than interpolated into the script: | |
| # an expression expanded inside `run:` is textual substitution. | |
| BUILD_MODE_INPUT: ${{ github.event.inputs.build_mode }} | |
| run: | | |
| # A fixed slice of GITHUB_SHA, not `git rev-parse --short`: git's | |
| # abbreviation length scales with object count, so it yields 7 here | |
| # (actions/checkout is shallow by default) and 8 in a full clone. | |
| # Pinning it keeps artifact names stable and identical across | |
| # platforms. | |
| echo "SHORT_SHA=${GITHUB_SHA:0:7}" >> $GITHUB_ENV | |
| # Identifier the artifacts are named after. On Linux the runner label | |
| # ("ubuntu-24.04-arm") is the wrong thing to publish: the build host's | |
| # distro is not what an AppImage runs on, and the "-arm" suffix would | |
| # read "arm-aarch64" next to the architecture. Just say "linux" — the | |
| # architecture already keeps the two images apart. Windows keeps its | |
| # runner label. | |
| if [[ "${{ runner.os }}" == "Linux" ]]; then | |
| echo "OS_IDENTIFIER=linux" >> $GITHUB_ENV | |
| else | |
| echo "OS_IDENTIFIER=${{ matrix.os }}" >> $GITHUB_ENV | |
| fi | |
| # Build flavour: "portable" keeps the profile beside the application, | |
| # "installed" uses the OS user-data location. Compile-time only. | |
| # "installed" is the default flavour, so only "portable" is spelled | |
| # out in artifact names. | |
| if [[ "${{ matrix.flavor }}" == "portable" ]]; then | |
| echo "GPGFRONTEND_BUILD_PORTABLE=ON" >> $GITHUB_ENV | |
| echo "FLAVOR_SUFFIX=-portable" >> $GITHUB_ENV | |
| else | |
| echo "GPGFRONTEND_BUILD_PORTABLE=OFF" >> $GITHUB_ENV | |
| echo "FLAVOR_SUFFIX=" >> $GITHUB_ENV | |
| fi | |
| # Single-branch (trunk + tags) model: | |
| # - a version tag (v*) -> stable release build | |
| # - a push to main -> nightly build | |
| # - a pull request -> PR validation build | |
| # - workflow_dispatch -> honour the chosen input | |
| if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | |
| BUILD_MODE="${BUILD_MODE_INPUT}" | |
| if [[ "${BUILD_MODE}" == "release" ]]; then | |
| BUILD_TYPE_EFFECTIVE="Release" | |
| else | |
| BUILD_TYPE_EFFECTIVE="RelWithDebInfo" | |
| fi | |
| elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then | |
| BUILD_MODE="pr" | |
| BUILD_TYPE_EFFECTIVE="RelWithDebInfo" | |
| elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then | |
| BUILD_MODE="release" | |
| BUILD_TYPE_EFFECTIVE="Release" | |
| else | |
| BUILD_MODE="nightly" | |
| BUILD_TYPE_EFFECTIVE="RelWithDebInfo" | |
| fi | |
| # Stable release builds drop the "Testing" suffix from the app name. | |
| if [[ "${BUILD_MODE}" == "release" ]]; then | |
| GPGFRONTEND_BUILD_STABLE="ON" | |
| else | |
| GPGFRONTEND_BUILD_STABLE="OFF" | |
| fi | |
| echo "BUILD_MODE=${BUILD_MODE}" >> $GITHUB_ENV | |
| echo "BUILD_TYPE=${BUILD_TYPE_EFFECTIVE}" >> $GITHUB_ENV | |
| echo "GPGFRONTEND_BUILD_STABLE=${GPGFRONTEND_BUILD_STABLE}" >> $GITHUB_ENV | |
| echo "BUILD_TYPE_LOWER=$(echo ${BUILD_TYPE_EFFECTIVE} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV | |
| echo "SANDBOX_CMAKE_FLAG=" >> $GITHUB_ENV | |
| echo "Build mode: ${BUILD_MODE}" | |
| echo "Build type: ${BUILD_TYPE_EFFECTIVE}" | |
| echo "Build flavor: ${{ matrix.flavor }}" | |
| # Before anything is built, because it costs a second and the failure it | |
| # catches costs a full matrix. | |
| # | |
| # Every `run:` block is its own process. A variable set in one step is | |
| # gone by the next, and neither shell says so usefully: bash under | |
| # `set -u` at least aborts, while PowerShell expands an undefined | |
| # variable to the EMPTY STRING -- which turned `--expect-count | |
| # "$EXPECTED"` into a gate expecting nothing, and made four correctly | |
| # verified modules look like a broken Authenticode binding. | |
| # | |
| # Two steps had that fault, in two shells, from one edit. That is the | |
| # shape of thing a check catches and a reader does not. | |
| - name: Check Workflow Steps Define What They Read | |
| run: python3 scripts/check_workflow_steps.py | |
| if: runner.os == 'Linux' | |
| - name: ccache | |
| uses: hendrikmuhs/ccache-action@f09c25b45002a07be2955cbe52e8cee55643f89d # v1.2.24 | |
| with: | |
| key: ${{ github.job }}-${{ matrix.os }}-${{ matrix.flavor }}-${{ env.BUILD_TYPE }} | |
| - name: Install Dependence (Linux) | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get -y install build-essential binutils git autoconf automake gettext texinfo | |
| sudo apt-get -y install gcc g++ ninja-build | |
| sudo apt-get -y install libarchive-dev libssl-dev libsodium-dev | |
| sudo apt-get -y install gpgsm libxcb-xinerama0 libxcb-icccm4-dev libcups2-dev libdrm-dev libegl1-mesa-dev | |
| sudo apt-get -y install libfuse2 libgcrypt20-dev libnss3-dev libpci-dev libpulse-dev libudev-dev libxtst-dev | |
| sudo apt-get -y install libglu1-mesa-dev libfontconfig1-dev libx11-xcb-dev libxcb-image0 gyp | |
| sudo apt-get -y install libglu1-mesa-dev libfontconfig1-dev libx11-xcb-dev libxcb-* libxkbcommon-x11-0 | |
| sudo apt-get -y install libwayland-cursor0 libwayland-egl1 | |
| # libsecret is dlopen'd, never linked. Installed only so the AppImage | |
| # can carry a copy built against the same glib it bundles. | |
| sudo apt-get -y install libsecret-1-0 | |
| if: runner.os == 'Linux' | |
| - name: Install Qt6 | |
| uses: jurplel/install-qt-action@48d3ad6db93f3627c8ee7a0454bc6f3744f7e730 # v4.3.1 | |
| with: | |
| version: "6.10.3" | |
| cache: "true" | |
| if: runner.os == 'Linux' | |
| - name: Set up MinGW (Windows) | |
| uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 | |
| id: msys2 | |
| with: | |
| update: false | |
| release: false | |
| cache: true | |
| install: >- | |
| git | |
| zip | |
| unzip | |
| msys2-devel | |
| base-devel | |
| msys2-runtime-devel | |
| mingw-w64-x86_64-gcc | |
| mingw-w64-x86_64-make | |
| mingw-w64-x86_64-cmake | |
| mingw-w64-x86_64-qt6-base | |
| mingw-w64-x86_64-qt6-tools | |
| mingw-w64-x86_64-ninja | |
| mingw-w64-x86_64-libarchive | |
| mingw-w64-x86_64-gtest | |
| mingw-w64-x86_64-autotools | |
| mingw-w64-x86_64-texinfo | |
| mingw-w64-x86_64-libassuan | |
| mingw-w64-x86_64-ccache | |
| mingw-w64-x86_64-rust | |
| mingw-w64-x86_64-libsodium | |
| mingw-w64-x86_64-openssl | |
| if: runner.os == 'Windows' | |
| - name: Install Rust | |
| # Pinned to a SHA, so the @stable ref name no longer selects the | |
| # toolchain; say it explicitly instead. | |
| uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch | |
| with: | |
| toolchain: stable | |
| if: runner.os == 'Linux' | |
| # The Rust crate does not see the portable flag, so both flavours produce | |
| # the same cargo output and deliberately share one cache entry. | |
| - name: Cache Cargo | |
| uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 | |
| with: | |
| workspaces: | | |
| rust -> build/cargo | |
| shared-key: rust-${{ runner.os }}-${{ matrix.os }}-${{ env.BUILD_TYPE }} | |
| cache-on-failure: true | |
| if: runner.os == 'Linux' | |
| # rust-cache cannot locate the msys2/mingw cargo, so cache the registry and | |
| # Corrosion's target dir directly for the Windows build. | |
| - name: Cache Cargo (Windows) | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: | | |
| ~/.cargo/registry/index | |
| ~/.cargo/registry/cache | |
| ~/.cargo/git/db | |
| ${{github.workspace}}/build/cargo | |
| key: cargo-win-${{ matrix.os }}-${{ env.BUILD_TYPE }}-${{ hashFiles('rust/Cargo.lock') }} | |
| restore-keys: | | |
| cargo-win-${{ matrix.os }}-${{ env.BUILD_TYPE }}- | |
| if: runner.os == 'Windows' | |
| - name: Build GpgME (Windows) | |
| shell: msys2 {0} | |
| run: | | |
| cd $(cygpath -u "${{github.workspace}}") | |
| cd third_party/gpgme | |
| export CC="ccache gcc" | |
| export CXX="ccache g++" | |
| export CFLAGS="${CFLAGS} -Wno-int-conversion -Wno-incompatible-pointer-types" | |
| ./autogen.sh | |
| mkdir -p build && cd build | |
| ../configure --enable-maintainer-mode \ | |
| --enable-static \ | |
| --disable-shared \ | |
| --enable-silent-rules \ | |
| --disable-dependency-tracking \ | |
| --enable-languages=cl \ | |
| --disable-gpgconf-test \ | |
| --disable-gpg-test \ | |
| --disable-gpgsm-test \ | |
| --disable-g13-test \ | |
| --enable-w32-glib | |
| make -j$(nproc) | |
| make install | |
| ccache -s | |
| if: runner.os == 'Windows' | |
| - name: Cache googletest (Linux) | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ${{github.workspace}}/third_party/googletest | |
| key: gtest-${{ matrix.os }}-v1.15.2 | |
| if: runner.os == 'Linux' | |
| - name: Build googletest (Linux) | |
| run: | | |
| if [ ! -f "${{github.workspace}}/third_party/googletest/build/build.ninja" ]; then | |
| rm -rf ${{github.workspace}}/third_party/googletest | |
| git clone --depth 1 --branch v1.15.2 https://github.com/google/googletest.git ${{github.workspace}}/third_party/googletest | |
| cd ${{github.workspace}}/third_party/googletest | |
| mkdir build && cd build | |
| cmake -G Ninja -DBUILD_SHARED_LIBS=ON \ | |
| -DCMAKE_C_COMPILER_LAUNCHER=ccache \ | |
| -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ | |
| .. | |
| ninja | |
| else | |
| echo "Reusing cached googletest build" | |
| fi | |
| cd ${{github.workspace}}/third_party/googletest/build | |
| sudo ninja install | |
| if: runner.os == 'Linux' | |
| - name: Build GpgFrontend (Linux) | |
| run: | | |
| export LD_LIBRARY_PATH=/usr/local/lib:/usr/local/lib64:$LD_LIBRARY_PATH | |
| # No GPGFRONTEND_BUILD_APP_IMAGE: release packaging no longer | |
| # redirects where the build puts things. The AppDir is produced by | |
| # `cmake --install` below, which is the same tree a distribution | |
| # gets, so CI stops assembling something nobody else can reproduce. | |
| cmake -B ${{github.workspace}}/build -G Ninja \ | |
| -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} \ | |
| -DCMAKE_INSTALL_PREFIX=/usr \ | |
| -DGPGFRONTEND_BUILD_STABLE=${{env.GPGFRONTEND_BUILD_STABLE}} \ | |
| -DGPGFRONTEND_BUILD_PORTABLE=${{env.GPGFRONTEND_BUILD_PORTABLE}} \ | |
| -DGPGFRONTEND_LINK_GPGME_INTO_CORE=ON | |
| cmake --build ${{ github.workspace }}/build \ | |
| --config ${{ env.BUILD_TYPE }} \ | |
| --parallel \ | |
| --verbose | |
| ccache -s | |
| if: runner.os == 'Linux' | |
| # The AppDir is an ordinary install tree. | |
| # | |
| # It used to be produced by redirecting every output directory at | |
| # configure time, which made the CI layout one no developer or packager | |
| # could reproduce and put the host build tools inside the image. Now the | |
| # job installs, exactly as a distribution would, and gets the modules, | |
| # the desktop entry, the metainfo, the MIME package and the icons | |
| # because install() already ships all of them. | |
| # | |
| # `cmake --install` verifies the module tree it writes, so this step also | |
| # proves the descriptors are good BEFORE deployment starts rewriting the | |
| # natives they bind. | |
| - name: Assemble The AppDir (Linux) | |
| run: | | |
| set -euo pipefail | |
| APP_DIR="${{github.workspace}}/build/artifacts/AppDir" | |
| rm -rf "$APP_DIR" | |
| cmake --install ${{github.workspace}}/build --prefix "$APP_DIR/usr" | |
| # The icon and .DirIcon AppImage wants at the root of the image, and | |
| # which a normal install correctly does not place. | |
| cmake --install ${{github.workspace}}/build --component appimage \ | |
| --prefix "$APP_DIR" | |
| if: runner.os == 'Linux' | |
| # Deployment, and the repair deployment makes necessary. | |
| # | |
| # linuxdeployqt REPLACES a file's RUNPATH with its own hop to usr/lib. | |
| # That is right for reaching Qt and wrong for reaching the private | |
| # helpers beside a module entry, which need $ORIGIN itself. The two were | |
| # separate steps; they are one operation on one tree, and splitting them | |
| # only made it possible to run the second without the first. | |
| - name: Deploy Qt Dependencies (Linux) | |
| run: | | |
| set -euo pipefail | |
| QT_PLUGIN_DIR=$(qmake -query QT_INSTALL_PLUGINS) | |
| echo "Found Qt plugin dir: $QT_PLUGIN_DIR" | |
| # remove all non-sqlite drivers to reduce the size of the final AppImage | |
| cd "$QT_PLUGIN_DIR/sqldrivers" | |
| find . -type f ! -name '*sqlite*' -delete | |
| ls -l | |
| cd ${{github.workspace}} | |
| mkdir -p ${{github.workspace}}/build/upload-artifact | |
| cd ${{github.workspace}}/build/upload-artifact | |
| ARCH=$(uname -m) | |
| if [[ "$ARCH" == "x86_64" ]]; then | |
| wget -c -nv https://github.com/probonopd/linuxdeployqt/releases/download/continuous/linuxdeployqt-continuous-x86_64.AppImage | |
| mv linuxdeployqt-continuous-x86_64.AppImage linuxdeployqt-continuous.AppImage | |
| EXTRA_ARGS="" | |
| elif [[ "$ARCH" == "aarch64" ]]; then | |
| wget -c -nv https://github.com/probonopd/linuxdeployqt/releases/download/continuous/linuxdeployqt-continuous-aarch64.AppImage | |
| mv linuxdeployqt-continuous-aarch64.AppImage linuxdeployqt-continuous.AppImage | |
| mkdir -p ${{github.workspace}}/build/artifacts/AppDir/usr/share/doc/libc6/ | |
| touch ${{github.workspace}}/build/artifacts/AppDir/usr/share/doc/libc6/copyright | |
| EXTRA_ARGS="-unsupported-allow-new-glibc" | |
| fi | |
| echo "ARCH=$ARCH" >> "$GITHUB_ENV" | |
| APP_DIR="${{github.workspace}}/build/artifacts/AppDir" | |
| # The AppImage bundles libglib/libgobject/libgio and its AppRun puts | |
| # them ahead of the host's copies, so a host libsecret built against a | |
| # newer glib cannot resolve its own symbols and the system keychain | |
| # simply disappears -- see linuxdeployqt issue 544. Carrying our own | |
| # copy is what makes the dependency closure self-consistent. It has to | |
| # be staged before linuxdeployqt runs: a file dropped in afterwards | |
| # gets neither an rpath nor its own dependencies deployed. | |
| LIBSECRET_SRC="/usr/lib/$(dpkg-architecture -qDEB_HOST_MULTIARCH)/libsecret-1.so.0" | |
| test -f "$LIBSECRET_SRC" | |
| cp -L "$LIBSECRET_SRC" "$APP_DIR/usr/lib/libsecret-1.so.0" | |
| # Anything the app needs out of /usr/local, staged the same way and | |
| # for the same reason: linuxdeployqt patches what is in the AppDir | |
| # when it runs, and a file dropped in afterwards gets neither an | |
| # rpath nor its own dependencies deployed. | |
| # | |
| # This is googletest in practice. The application links gf_test -- | |
| # `gpgfrontend -t` runs the suite from the shipped binary -- and CI | |
| # builds googletest as a SHARED library into /usr/local/lib, which is | |
| # a directory that exists on this runner and on nobody's desktop. The | |
| # AppImage started, looked for libgtest.so.1.15.2, and died before | |
| # main(). A developer building locally never sees it, because a local | |
| # googletest is usually static. | |
| # | |
| # Resolved rather than named: /usr/local is where a hand-built | |
| # dependency lands, and the next one should not need this comment | |
| # rewritten. | |
| APP_BIN="$(find "$APP_DIR/usr/bin" -maxdepth 1 -type f -perm -u+x \ | |
| | head -1)" | |
| test -n "$APP_BIN" || { echo "no application binary in the AppDir" >&2; exit 1; } | |
| staged=0 | |
| while IFS= read -r lib; do | |
| [ -n "$lib" ] || continue | |
| cp -L "$lib" "$APP_DIR/usr/lib/$(basename "$lib")" | |
| echo "staged $(basename "$lib") from /usr/local" | |
| staged=$((staged + 1)) | |
| done < <(LD_LIBRARY_PATH="$APP_DIR/usr/lib:/usr/local/lib:/usr/local/lib64" \ | |
| ldd "$APP_BIN" \ | |
| | awk '$3 ~ /^\/usr\/local\// { print $3 }' | sort -u) | |
| echo "$staged library/libraries staged from /usr/local" | |
| # Every module native, named individually. `-executable-dir=` took a | |
| # single flat directory, which the namespace layout no longer has: | |
| # each module owns usr/lib/gpgfrontend/modules/<key>/native/. | |
| MODULE_ROOT="$APP_DIR/usr/lib/gpgfrontend/modules" | |
| test -d "$MODULE_ROOT" | |
| EXECUTABLES=() | |
| while read -r native; do | |
| EXECUTABLES+=("-executable=$native") | |
| done < <(find "$MODULE_ROOT" -type f -name '*.so' | sort) | |
| test "${#EXECUTABLES[@]}" -gt 0 | |
| echo "deploying ${#EXECUTABLES[@]} module native(s)" | |
| chmod u+x linuxdeployqt-continuous.AppImage | |
| export LD_LIBRARY_PATH=/usr/local/lib:/usr/local/lib64:$LD_LIBRARY_PATH | |
| # Deliberately no -appimage: the image is built at the end, after the | |
| # descriptors have been regenerated against the deployed natives. | |
| ./linuxdeployqt-continuous.AppImage \ | |
| "$APP_DIR/usr/share/applications"/*.desktop \ | |
| $EXTRA_ARGS \ | |
| -no-translations \ | |
| -extra-plugins=iconengines,platforms,sqldrivers/libqsqlite.so \ | |
| -executable=$APP_DIR/usr/lib/libsecret-1.so.0 \ | |
| "${EXECUTABLES[@]}" | |
| # Without the rpath patch the staged copy cannot find its own | |
| # dependencies, which is the bug this whole step exists to fix, so it | |
| # fails the build rather than shipping a silent regression. | |
| echo "--- deployed credential-store closure ---" | |
| ls -l "$APP_DIR/usr/lib" | grep -E 'secret|glib|gobject|gio|gcrypt' || true | |
| readelf -d "$APP_DIR/usr/lib/libsecret-1.so.0" | grep -E 'RUNPATH|RPATH|SONAME|NEEDED' || true | |
| readelf -d "$APP_DIR/usr/lib/libsecret-1.so.0" | grep -q 'ORIGIN' \ | |
| || { echo "libsecret was not rpath-patched by linuxdeployqt"; exit 1; } | |
| # Put $ORIGIN back on every module native. Unconditional and | |
| # idempotent rather than conditional on what linuxdeployqt did: a | |
| # repair that only runs when someone predicted the need is a repair | |
| # that stops running, and the audit below is the gate either way. | |
| sudo apt-get install -y --no-install-recommends patchelf | |
| while read -r native; do | |
| current="$(patchelf --print-rpath "$native" || true)" | |
| case ":$current:" in | |
| *':$ORIGIN:'*) want="$current" ;; | |
| '::') want='$ORIGIN:$ORIGIN/../../../..' ;; | |
| *) want="\$ORIGIN:$current" ;; | |
| esac | |
| patchelf --set-rpath "$want" "$native" | |
| echo "$(basename "$native"): $want" | |
| done < <(find "$MODULE_ROOT" -type f -name '*.so' | sort) | |
| if: runner.os == 'Linux' | |
| # The natives were just rewritten, so the descriptors that bind them | |
| # are stale by construction. This re-describes them and then proves the | |
| # result, in one step, because it is one operation on one tree. | |
| # | |
| # NOT by deleting the descriptors and re-running the build, which is the | |
| # obvious thing and is wrong here: the natives are build outputs, so a | |
| # `cmake --build` at this point sees that linuxdeployqt and patchelf | |
| # changed them underneath it and RELINKS them, discarding the rpath work | |
| # of the step above. `reseal` does not involve the build graph at all. | |
| # | |
| # The audit is the third part and has to come last: it asks the packager | |
| # which native each module's SIGNED descriptor binds, so it needs | |
| # descriptors that verify. It is kept -- unlike the other intermediate | |
| # gates -- because it proves something the smoke test structurally | |
| # cannot: that no dependency resolves through a path that exists on this | |
| # runner and not on a user's machine. | |
| - name: Refresh And Verify Module Descriptors (Linux) | |
| run: | | |
| set -euo pipefail | |
| INFO="${{github.workspace}}/build/artifacts/build-info.json" | |
| MODULE_ROOT="${{github.workspace}}/build/artifacts/AppDir/usr/lib/gpgfrontend/modules" | |
| # Everything this step needs to know, from the build system that | |
| # decided it. The count, the tool path and the layout used to be | |
| # re-derived here by hand, in eleven, eight and seventeen places | |
| # respectively across these workflows. | |
| PACKAGER="$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['module_packager'])" "$INFO")" | |
| EXPECTED="$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['module_count'])" "$INFO")" | |
| "$PACKAGER" reseal \ | |
| --namespace-root "$MODULE_ROOT" \ | |
| --signing-seed ${{github.workspace}}/build/.module-build-key/module-build.seed \ | |
| --expect-count "$EXPECTED" | |
| # NOTHING may touch a module native after this point. | |
| "$PACKAGER" verify-module-set \ | |
| --namespace-root "$MODULE_ROOT" \ | |
| --expect-count "$EXPECTED" \ | |
| --assert-no-native-outside ${{github.workspace}}/build/artifacts/AppDir | |
| ${{github.workspace}}/scripts/audit_module_natives.sh \ | |
| --namespace-root "$MODULE_ROOT" \ | |
| --packager "$PACKAGER" \ | |
| --qt-relative ../../../.. \ | |
| --expect-count "$EXPECTED" | |
| if: runner.os == 'Linux' | |
| - name: Build AppImage (Linux) | |
| run: | | |
| set -euo pipefail | |
| cd ${{github.workspace}}/build/upload-artifact | |
| if [[ "$ARCH" == "x86_64" ]]; then | |
| wget -c -nv https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage | |
| mv appimagetool-x86_64.AppImage appimagetool.AppImage | |
| else | |
| wget -c -nv https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-aarch64.AppImage | |
| mv appimagetool-aarch64.AppImage appimagetool.AppImage | |
| fi | |
| chmod u+x appimagetool.AppImage | |
| # Same naming scheme as every other platform. linuxdeployqt's own | |
| # -appimage named the image after the .desktop entry, so both | |
| # flavours came out as Gpg_Frontend-<arch>.AppImage and collided once | |
| # the release job merged every runner's artifacts into one directory. | |
| OUTPUT="GpgFrontend-${{env.SHORT_SHA}}-${{env.OS_IDENTIFIER}}-${ARCH}${{env.FLAVOR_SUFFIX}}.AppImage" | |
| ARCH="$ARCH" ./appimagetool.AppImage \ | |
| ${{github.workspace}}/build/artifacts/AppDir "$OUTPUT" | |
| rm -f linuxdeployqt-continuous.AppImage appimagetool.AppImage | |
| ls -l | |
| if: runner.os == 'Linux' | |
| - name: Build GpgFrontend (Windows) | |
| shell: msys2 {0} | |
| run: | | |
| cd $(cygpath -u "${{github.workspace}}") | |
| cmake -G "Ninja" -S . -B build \ | |
| -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} \ | |
| -DGPGFRONTEND_BUILD_STABLE=${{env.GPGFRONTEND_BUILD_STABLE}} \ | |
| -DGPGFRONTEND_BUILD_PORTABLE=${{env.GPGFRONTEND_BUILD_PORTABLE}} \ | |
| -DGPGFRONTEND_LINK_GPGME_INTO_CORE=ON | |
| cmake --build build \ | |
| --config ${{ env.BUILD_TYPE }} \ | |
| --parallel \ | |
| --verbose | |
| ccache -s | |
| if: runner.os == 'Windows' | |
| - name: Download GnuPG Binary Release (Windows) | |
| shell: msys2 {0} | |
| run: | | |
| export URL="https://ftp.bktus.com/GnuPG/${{env.GNUPG_VERSION}}" | |
| export KEY_URL="https://bktus.com/pgp/saturneric-code-signing.asc" | |
| export KEY_FPR="12F7E8858CF15BEC9975FF3C5CA3DA246843FD03" | |
| export KEY_FILE="saturneric-code-signing.asc" | |
| export FILE="gnupg.zip" | |
| export CHECKSUM_FILE="SHA256SUMS.txt" | |
| export SIGNATURE_FILE="gnupg.zip.sig" | |
| export GNUPGHOME=$(mktemp -d) | |
| cd $(cygpath -u "${{github.workspace}}") | |
| mkdir -p build/downloads | |
| curl -fL --retry 3 -o build/downloads/$FILE $URL/$FILE | |
| curl -fL --retry 3 -o build/downloads/$CHECKSUM_FILE $URL/$CHECKSUM_FILE | |
| curl -fL --retry 3 -o build/downloads/$KEY_FILE $KEY_URL | |
| curl -fL --retry 3 -o build/downloads/$SIGNATURE_FILE $URL/$SIGNATURE_FILE | |
| gpg --import build/downloads/$KEY_FILE | |
| # Trust is pinned to this exact fingerprint, not to whatever the | |
| # downloaded key file happens to contain. | |
| if ! KEY_INFO=$(gpg --batch --with-colons --list-keys "$KEY_FPR"); then | |
| echo "Imported key does not match fingerprint $KEY_FPR!" >&2 | |
| exit 1 | |
| fi | |
| EXPIRES=$(echo "$KEY_INFO" | awk -F: '/^pub:/ {print $7; exit}') | |
| if [ -n "$EXPIRES" ]; then | |
| echo "Signing key expires: $(date -u -d "@$EXPIRES")" | |
| if [ "$EXPIRES" -le "$(date +%s)" ]; then | |
| echo "Signing key has expired!" >&2 | |
| exit 1 | |
| fi | |
| else | |
| echo "Signing key has no expiration date" | |
| fi | |
| # VALIDSIG carries the primary key fingerprint as its last field, so | |
| # this also rejects a valid signature from any other imported key. | |
| if ! gpg --status-fd 1 --verify build/downloads/$SIGNATURE_FILE \ | |
| build/downloads/$FILE | grep "VALIDSIG" | grep -q "$KEY_FPR"; then | |
| echo "GnuPG signature verification failed!" >&2 | |
| exit 1 | |
| fi | |
| CHECKSUM=$(grep "$FILE\$" build/downloads/$CHECKSUM_FILE | awk '{print $1}') | |
| ACTUAL_CHECKSUM=$(sha256sum build/downloads/$FILE | awk '{print $1}') | |
| echo "Expected Checksum: $CHECKSUM" | |
| echo "Actual Checksum: $ACTUAL_CHECKSUM" | |
| if [ "$CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then | |
| echo "Checksum verification failed!" >&2 | |
| exit 1 | |
| fi | |
| mkdir -p build/artifacts/gnupg | |
| # Extraction has to be byte-exact. A text-mode extractor rewrites every | |
| # LF as CRLF, which shifts each PE image away from the offset its | |
| # e_lfanew field points at: the staged binaries then carry no readable | |
| # Authenticode signature and Windows refuses to load them, while the | |
| # archive-level OpenPGP and SHA256 checks above still pass because the | |
| # downloaded zip is intact. bsdtar has no text mode at all. | |
| if command -v bsdtar >/dev/null 2>&1; then | |
| bsdtar -xf build/downloads/$FILE -C build/artifacts/gnupg | |
| else | |
| unzip -o build/downloads/$FILE -d build/artifacts/gnupg/ | |
| fi | |
| # Gate the staged payload rather than trusting the extractor: every | |
| # image must still start with MZ and hold the PE signature exactly | |
| # where e_lfanew points. | |
| BROKEN=0 | |
| COUNT=0 | |
| while IFS= read -r pe; do | |
| COUNT=$((COUNT + 1)) | |
| MZ=$(dd if="$pe" bs=1 count=2 2>/dev/null | od -An -tx1 | tr -d ' \n') | |
| OFF=$(od -An -tu4 -j 60 -N 4 "$pe" | tr -d ' ') | |
| SIG=$(dd if="$pe" bs=1 skip="$OFF" count=4 2>/dev/null | od -An -tx1 | tr -d ' \n') | |
| if [ "$MZ" != "4d5a" ] || [ "$SIG" != "50450000" ]; then | |
| echo "corrupt PE image: $pe (mz=$MZ e_lfanew=$OFF sig=$SIG)" >&2 | |
| BROKEN=$((BROKEN + 1)) | |
| fi | |
| done < <(find build/artifacts/gnupg -type f \( -name '*.exe' -o -name '*.dll' \)) | |
| if [ "$BROKEN" -ne 0 ]; then | |
| echo "$BROKEN of $COUNT staged GnuPG images are not loadable PE files!" >&2 | |
| type -a bsdtar unzip >&2 || true | |
| env | grep -iE '^(UNZIP|UNZIPOPT|ZIPOPT|MSYS|CYGWIN)=' >&2 || true | |
| exit 1 | |
| fi | |
| echo "verified $COUNT staged GnuPG PE images" | |
| ls -l build/artifacts/gnupg/ | |
| if: runner.os == 'Windows' | |
| # Payload staging is flavour-independent: the portable ZIP and the MSI are | |
| # both built from this same tree, only from a differently configured build. | |
| - name: Stage Payload (Windows) | |
| shell: msys2 {0} | |
| run: | | |
| cd $(cygpath -u "${{github.workspace}}") | |
| cp PrivacyPolicy.md build/artifacts/ | |
| cp README.md build/artifacts/ | |
| cp SECURITY.md build/artifacts/ | |
| cp TRANSLATORS build/artifacts/ | |
| cp COPYING build/artifacts/ | |
| cp gpgfrontend.ico build/artifacts/bin/ | |
| rm -rf build/artifacts/bin/*.a | |
| # No `mv build/artifacts/bin/modules build/artifacts/modules` any | |
| # more: CMake writes each module straight into its own namespace at | |
| # build/artifacts/modules/<key>/{module.gfmodule,native/}, which is | |
| # exactly where the Host looks (<appdir>/../modules). Moving a | |
| # directory into place after the fact was the step that made the old | |
| # layout's ordering load-bearing. | |
| rm -rf build/artifacts/modules/*/native/*.a | |
| rm -rf build/artifacts/modules/*/native/*.dll.a | |
| cd build | |
| # The libraries CMake registered, read from the list it writes -- | |
| # not a `libgf_*.dll` glob. The glob kept the "a new library cannot | |
| # be forgotten" property and added a sharp edge: it matches by name, | |
| # so a test fixture called libgf_mod_test_sentinel.dll was handed to | |
| # windeployqt6, which correctly reported that it is not a Qt | |
| # executable and stopped the build. | |
| while IFS= read -r lib; do | |
| [ -n "$lib" ] || continue | |
| test -f "./artifacts/bin/$lib" || { | |
| echo "registered library $lib is not in artifacts/bin" >&2 | |
| exit 1 | |
| } | |
| windeployqt6 --no-translations --force "./artifacts/bin/$lib" | |
| done < <(${{github.workspace}}/scripts/build_info.sh ${{github.workspace}}/build deployable_libraries) | |
| windeployqt6 --no-translations --force ./artifacts/bin/GpgFrontend.exe | |
| # A module may need Qt modules the app itself never links (Qt6Xml, | |
| # Qt6Network, ...), so each one still has to be scanned. But --dir | |
| # sends what it needs into bin/, which is the first directory the | |
| # loader searches, instead of duplicating the whole Qt runtime next | |
| # to every module. | |
| for module in $(find ./artifacts/modules -type f -name '*.dll' | sort); do | |
| windeployqt6 --no-translations --force --dir ./artifacts/bin "$module" | |
| done | |
| mkdir -p upload-artifact | |
| if: runner.os == 'Windows' | |
| # windeployqt has just run. It reads the module DLLs and writes only | |
| # into bin/, so the descriptors still describe what is there -- and this | |
| # proves that rather than assuming it. | |
| # | |
| # The audit comes last within the step: it asks the packager which native | |
| # each module's signed descriptor binds, so it needs descriptors that | |
| # verify. | |
| - name: Verify Module Descriptors (Windows) | |
| shell: msys2 {0} | |
| run: | | |
| set -euo pipefail | |
| cd $(cygpath -u "${{github.workspace}}") | |
| MODULE_ROOT="build/artifacts/modules" | |
| PACKAGER="$(${{github.workspace}}/scripts/build_info.sh ${{github.workspace}}/build module_packager)" | |
| EXPECTED="$(${{github.workspace}}/scripts/build_info.sh ${{github.workspace}}/build module_count)" | |
| "$PACKAGER" verify-module-set \ | |
| --namespace-root "$MODULE_ROOT" \ | |
| --expect-count "$EXPECTED" \ | |
| --assert-no-native-outside build/artifacts | |
| ./scripts/audit_module_natives.sh \ | |
| --namespace-root "$MODULE_ROOT" \ | |
| --packager "$PACKAGER" \ | |
| --expect-count "$EXPECTED" | |
| if: runner.os == 'Windows' | |
| # The claim §14.2 rests on, checked against the binaries this build | |
| # actually produced rather than against the specification. | |
| # | |
| # The PE Authenticode image digest skips exactly the three regions | |
| # signing and timestamping write -- the checksum, the certificate table | |
| # directory entry, and the certificate table itself -- so a module DLL | |
| # may be signed after its descriptor is final, by CI or by hand, with no | |
| # access to the build key. That is a strong claim about a format, and a | |
| # format claim that is only ever asserted is one that quietly stops being | |
| # true. | |
| # | |
| # Signed with a throwaway self-signed certificate: the point is that the | |
| # binding survives the FILE CHANGES signing makes, and nothing here cares | |
| # who signed it. No release key is involved and none is needed. | |
| - name: Verify The Authenticode Contract (Windows) | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $root = "${{github.workspace}}/build/artifacts/modules" | |
| # One source for both, read the way every other step reads it. | |
| $info = Get-Content "${{github.workspace}}/build/artifacts/build-info.json" -Raw | ConvertFrom-Json | |
| $packager = $info.module_packager | |
| # Derived HERE, not inherited. A `$EXPECTED` set by an earlier step | |
| # is that step's shell variable and nothing else: it does not survive | |
| # into this one, and PowerShell expands an undefined variable to the | |
| # empty string rather than complaining. Same source as every other | |
| # leg -- what CMake recorded at configure time -- so adding a fifth | |
| # module raises this expectation with the rest. | |
| $expected = $info.module_count | |
| if ($expected -lt 1) { throw "build-info.json declares no modules" } | |
| # A scratch copy of the whole tree, so a failure here cannot damage | |
| # what is about to ship. | |
| $scratch = Join-Path $env:RUNNER_TEMP 'authenticode-gate' | |
| Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $scratch | |
| Copy-Item -Recurse $root $scratch | |
| $cert = New-SelfSignedCertificate ` | |
| -Type CodeSigningCert ` | |
| -Subject 'CN=GpgFrontend Authenticode Gate (throwaway)' ` | |
| -CertStoreLocation Cert:\CurrentUser\My | |
| $natives = Get-ChildItem -Path $scratch -Recurse -Filter '*.dll' | |
| if ($natives.Count -lt 1) { throw 'no module natives to sign' } | |
| foreach ($native in $natives) { | |
| Set-AuthenticodeSignature -FilePath $native.FullName ` | |
| -Certificate $cert -HashAlgorithm SHA256 | Out-Null | |
| } | |
| # Signing must have CHANGED the files -- otherwise this gate would | |
| # pass without testing anything at all. | |
| foreach ($native in $natives) { | |
| $original = Join-Path $root ($native.FullName.Substring($scratch.Length + 1)) | |
| $a = (Get-FileHash $original -Algorithm SHA256).Hash | |
| $b = (Get-FileHash $native.FullName -Algorithm SHA256).Hash | |
| if ($a -eq $b) { | |
| throw "signing did not change $($native.Name); this gate proves nothing" | |
| } | |
| } | |
| & $packager verify-module-set --namespace-root $scratch --expect-count $expected | |
| if ($LASTEXITCODE -ne 0) { | |
| throw 'Authenticode signing broke the entry binding' | |
| } | |
| # The other half of the contract, and the half that was never | |
| # proven here: certificates are invisible to this digest, and CODE | |
| # IS NOT. | |
| # | |
| # Without this, a digest that covered nothing at all would pass | |
| # everything above -- signing would not break it, because nothing | |
| # could. That is not hypothetical: the implementation this replaced | |
| # disagreed with itself about the padding a signer inserts, and the | |
| # unit tests said it was right because the synthetic fixture they | |
| # used happened to be eight-byte aligned. | |
| $victim = $natives[0].FullName | |
| $bytes = [System.IO.File]::ReadAllBytes($victim) | |
| # Inside the first section's raw data: past the headers, far from the | |
| # checksum and the certificate table, which are the regions the | |
| # digest is supposed to ignore. | |
| $at = 1024 | |
| $bytes[$at] = $bytes[$at] -bxor 0xFF | |
| [System.IO.File]::WriteAllBytes($victim, $bytes) | |
| & $packager verify-module-set --namespace-root $scratch --expect-count $expected | |
| if ($LASTEXITCODE -eq 0) { | |
| throw "executable content was changed in $([System.IO.Path]::GetFileName($victim)) and the binding still verified; this digest covers nothing" | |
| } | |
| Write-Host 'mutated executable content was refused, as it must be' | |
| Remove-Item -Recurse -Force $scratch | |
| Remove-Item -Force ("Cert:\CurrentUser\My\" + $cert.Thumbprint) | |
| if: runner.os == 'Windows' | |
| - name: Smoke Test The AppImage (Linux) | |
| run: | | |
| set -euo pipefail | |
| EXPECTED="$(${{github.workspace}}/scripts/build_info.sh ${{github.workspace}}/build module_count)" | |
| cd "$RUNNER_TEMP" | |
| IMAGE="$(find ${{github.workspace}}/build/upload-artifact \ | |
| -name '*.AppImage' | head -1)" | |
| test -n "$IMAGE" || { echo "no AppImage was built" >&2; exit 1; } | |
| rm -rf squashfs-root | |
| "$IMAGE" --appimage-extract >/dev/null | |
| # A profile that has never existed, and nothing of the runner's | |
| # touched. XDG_* as well as HOME: the defaults derive from HOME, but | |
| # only if nothing else is already exported. | |
| export HOME="$RUNNER_TEMP/smoke-home" | |
| export XDG_DATA_HOME="$HOME/.local/share" | |
| export XDG_CONFIG_HOME="$HOME/.config" | |
| export XDG_CACHE_HOME="$HOME/.cache" | |
| mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" | |
| # Offscreen only if it is actually deployed. linuxdeployqt is asked | |
| # for `-extra-plugins=platforms`, which brings all of them including | |
| # this one -- but asking unconditionally for a plugin that is not | |
| # there aborts before main() with "no Qt platform plugin could be | |
| # initialized", which reads like a broken build and is not one. That | |
| # is exactly how the macOS leg failed. | |
| if find ./squashfs-root -name 'libqoffscreen.so' | grep -q .; then | |
| export QT_QPA_PLATFORM=offscreen | |
| else | |
| echo "no offscreen plugin deployed; using the default platform" | |
| fi | |
| STATUS="$RUNNER_TEMP/module-status.json" | |
| rc=0 | |
| ./squashfs-root/AppRun --module-status "$STATUS" \ | |
| >"$RUNNER_TEMP/smoke-stdout.log" 2>&1 || rc=$? | |
| if [ ! -f "$STATUS" ]; then | |
| echo "the AppImage produced no status report (exit $rc)" >&2 | |
| echo "--- stdout (a missing shared library appears here) ---" >&2 | |
| cat "$RUNNER_TEMP/smoke-stdout.log" >&2 || true | |
| exit 1 | |
| fi | |
| cat "$STATUS" | |
| ${{github.workspace}}/scripts/check_module_status.py "$STATUS" "$EXPECTED" | |
| test "$rc" -eq 0 || { echo "the app exited $rc" >&2; exit 1; } | |
| if: runner.os == 'Linux' | |
| - name: Remove Build-Only Files From The Payload (Windows) | |
| shell: msys2 {0} | |
| run: | | |
| set -euo pipefail | |
| cd $(cygpath -u "${{github.workspace}}") | |
| TOOL_DIR="$(${{github.workspace}}/scripts/build_info.sh ${{github.workspace}}/build host_tool_dir)" | |
| rm -f "$TOOL_DIR/gf_module_packager.exe" "$TOOL_DIR/gf_module_keygen.exe" | |
| # Test fixtures went during payload staging, before the module-set | |
| # assertion could trip over them. The check below is what proves it. | |
| # Stated as a check rather than assumed: this is the last chance to | |
| # notice, and a stray build tool in a signed installer is the kind of | |
| # thing found by a user rather than by us. | |
| STRAY="$(find build/artifacts \( -name 'gf_module_packager*' \ | |
| -o -name 'gf_module_keygen*' \ | |
| -o -name '*test_sentinel*' \) -print)" | |
| if [ -n "$STRAY" ]; then | |
| echo "these are not deliverables and are still in the payload:" >&2 | |
| printf '%s\n' "$STRAY" >&2 | |
| exit 1 | |
| fi | |
| if: runner.os == 'Windows' | |
| - name: Package Portable Archive (Windows) | |
| shell: msys2 {0} | |
| run: | | |
| cd $(cygpath -u "${{github.workspace}}")/build/artifacts | |
| zip -r ../upload-artifact/GpgFrontend-${{env.SHORT_SHA}}-${{env.OS_IDENTIFIER}}-x86_64-portable.zip * | |
| if: runner.os == 'Windows' && matrix.flavor == 'portable' | |
| - name: Build MSI Installer (Windows) | |
| shell: pwsh | |
| run: | | |
| # The version the build actually used, asked of the build. Scraping it | |
| # out of CMakeLists.txt with a regular expression worked until the | |
| # day someone reformatted that line. | |
| $info = Get-Content "${{github.workspace}}/build/artifacts/build-info.json" -Raw | ConvertFrom-Json | |
| $version = $info.project_version | |
| Write-Host "Project version: $version" | |
| # Toolset and extension versions must match: an unpinned extension resolves | |
| # to the latest major (7.x), which a WiX 5 host cannot load (wixext5 vs wixext7). | |
| $wixVersion = '5.0.2' | |
| dotnet tool install --global wix --version $wixVersion | |
| wix extension add -g WixToolset.UI.wixext/$wixVersion | |
| wix extension add -g WixToolset.Util.wixext/$wixVersion | |
| New-Item -ItemType Directory -Force -Path "${{github.workspace}}/build/upload-artifact" | Out-Null | |
| # -arch x64 is mandatory: the package is MsiPackageType=x64. | |
| # PayloadDir / ProductVersion / IconSource / BrandingDir override the wxs | |
| # defaults for this runner (its defaults assume a build run from resource/wix). | |
| wix build -arch x64 ` | |
| -ext WixToolset.UI.wixext/$wixVersion ` | |
| -ext WixToolset.Util.wixext/$wixVersion ` | |
| -d PayloadDir="${{github.workspace}}/build/artifacts" ` | |
| -d ProductVersion="$version" ` | |
| -d IconSource="${{github.workspace}}/gpgfrontend.ico" ` | |
| -d BrandingDir="${{github.workspace}}/resource/wix" ` | |
| -o "${{github.workspace}}/build/upload-artifact/GpgFrontend-${{env.SHORT_SHA}}-${{env.OS_IDENTIFIER}}-x86_64.msi" ` | |
| resource/wix/Package.wxs | |
| # wix drops a .wixpdb next to the .msi; it is build metadata, not a deliverable. | |
| Remove-Item -Force -ErrorAction SilentlyContinue ` | |
| "${{github.workspace}}/build/upload-artifact/*.wixpdb" | |
| # An MSI installs into Program Files and keeps its data in the user | |
| # profile, so it is only ever built from the installed flavour. | |
| if: runner.os == 'Windows' && matrix.flavor == 'installed' | |
| - name: Smoke Test The Portable Archive (Windows) | |
| shell: msys2 {0} | |
| run: | | |
| set -euo pipefail | |
| cd $(cygpath -u "${{github.workspace}}") | |
| ZIP="$(find build/upload-artifact -name '*-portable.zip' | head -1)" | |
| test -n "$ZIP" || { echo "no portable archive was built" >&2; exit 1; } | |
| SMOKE="$(cygpath -u "$RUNNER_TEMP")/smoke" | |
| rm -rf "$SMOKE" | |
| mkdir -p "$SMOKE" | |
| unzip -q "$ZIP" -d "$SMOKE" | |
| test -f "$SMOKE/bin/GpgFrontend.exe" | |
| # A CI runner has no desktop to draw on, and windeployqt deploys only | |
| # the `windows` platform plugin. The offscreen one is copied into the | |
| # EXTRACTED tree, never into the shipped payload: it is needed to run | |
| # the test and would be dead weight in a user's download. It cannot | |
| # affect module loading either way. | |
| OFFSCREEN=/mingw64/share/qt6/plugins/platforms/qoffscreen.dll | |
| if [ -f "$OFFSCREEN" ]; then | |
| cp "$OFFSCREEN" "$SMOKE/bin/platforms/" | |
| export QT_QPA_PLATFORM=offscreen | |
| else | |
| echo "::warning::no offscreen plugin; trying the default platform" | |
| fi | |
| # A profile that has never existed. Portable builds keep the profile | |
| # beside the executable, so most of this lands under $SMOKE anyway -- | |
| # the rest is set so that nothing reaches the runner's own profile. | |
| export USERPROFILE="$(cygpath -w "$SMOKE/home")" | |
| export LOCALAPPDATA="$(cygpath -w "$SMOKE/home/AppData/Local")" | |
| export APPDATA="$(cygpath -w "$SMOKE/home/AppData/Roaming")" | |
| mkdir -p "$SMOKE/home/AppData/Local" "$SMOKE/home/AppData/Roaming" | |
| # How many modules this build produced, from the list CMake writes | |
| # at configure time. Never a literal: a hardcoded count means adding | |
| # a fifth module breaks every gate at once, and the obvious repair | |
| # is to bump the number in each, which is how a gate stops checking. | |
| EXPECTED="$(${{github.workspace}}/scripts/build_info.sh ${{github.workspace}}/build module_count)" | |
| STATUS="$SMOKE/module-status.json" | |
| cd "$SMOKE/bin" | |
| rc=0 | |
| ./GpgFrontend.exe --module-status "$(cygpath -w "$STATUS")" \ | |
| >"$SMOKE/stdout.log" 2>&1 || rc=$? | |
| if [ ! -f "$STATUS" ]; then | |
| echo "the portable build produced no status report (exit $rc)" >&2 | |
| echo "--- stdout (a missing DLL announces itself here) ---" >&2 | |
| cat "$SMOKE/stdout.log" >&2 || true | |
| exit 1 | |
| fi | |
| cat "$STATUS" | |
| python "$(cygpath -u "${{github.workspace}}")/scripts/check_module_status.py" \ | |
| "$STATUS" "$EXPECTED" | |
| test "$rc" -eq 0 || { echo "the app exited $rc" >&2; exit 1; } | |
| if: runner.os == 'Windows' && matrix.flavor == 'portable' | |
| - name: Upload Artifact (Linux) | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: gpgfrontend-${{matrix.os}}-${{matrix.flavor}}-${{env.BUILD_TYPE_LOWER}}-${{env.SHORT_SHA}} | |
| path: | | |
| ${{github.workspace}}/build/upload-artifact/GpgFrontend-*.AppImage* | |
| ${{github.workspace}}/build/artifacts/build-info.json | |
| if: runner.os == 'Linux' | |
| - name: Upload Artifact (Windows) | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: gpgfrontend-${{matrix.os}}-${{matrix.flavor}}-${{env.BUILD_TYPE_LOWER}}-${{env.SHORT_SHA}} | |
| path: ${{github.workspace}}/build/upload-artifact/* | |
| if: runner.os == 'Windows' | |
| # Compiles and deploys the macOS bundle. Deliberately holds no secrets: the | |
| # Developer ID key must never share a runner with brew, cargo build scripts, | |
| # recursive submodules or third-party build actions. The bundle leaves here | |
| # unsigned and is signed by sign-macos below. | |
| build-macos: | |
| strategy: | |
| matrix: | |
| # macOS ships only the installed flavour: the app is a notarized bundle | |
| # in /Applications, so a portable layout has no meaning there. | |
| os: ["macos-15-intel", "macos-15", "macos-26-intel", "macos-26"] | |
| runs-on: ${{ matrix.os }} | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 | |
| with: | |
| lfs: "false" | |
| submodules: recursive | |
| - name: Setup Build Mode | |
| shell: bash | |
| env: | |
| # Read through an env var rather than interpolated into the script: | |
| # an expression expanded inside `run:` is textual substitution. | |
| BUILD_MODE_INPUT: ${{ github.event.inputs.build_mode }} | |
| run: | | |
| # Single-branch (trunk + tags) model: | |
| # - a version tag (v*) -> stable release build | |
| # - a push to main -> nightly build | |
| # - a pull request -> PR validation build | |
| # - workflow_dispatch -> honour the chosen input | |
| if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | |
| BUILD_MODE="${BUILD_MODE_INPUT}" | |
| if [[ "${BUILD_MODE}" == "release" ]]; then | |
| BUILD_TYPE_EFFECTIVE="Release" | |
| else | |
| BUILD_TYPE_EFFECTIVE="RelWithDebInfo" | |
| fi | |
| elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then | |
| BUILD_MODE="pr" | |
| BUILD_TYPE_EFFECTIVE="RelWithDebInfo" | |
| elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then | |
| BUILD_MODE="release" | |
| BUILD_TYPE_EFFECTIVE="Release" | |
| else | |
| BUILD_MODE="nightly" | |
| BUILD_TYPE_EFFECTIVE="RelWithDebInfo" | |
| fi | |
| # Stable release builds drop the "Testing" suffix from the app name. | |
| if [[ "${BUILD_MODE}" == "release" ]]; then | |
| GPGFRONTEND_BUILD_STABLE="ON" | |
| else | |
| GPGFRONTEND_BUILD_STABLE="OFF" | |
| fi | |
| { | |
| echo "BUILD_MODE=${BUILD_MODE}" | |
| echo "BUILD_TYPE=${BUILD_TYPE_EFFECTIVE}" | |
| echo "GPGFRONTEND_BUILD_STABLE=${GPGFRONTEND_BUILD_STABLE}" | |
| } >> "$GITHUB_ENV" | |
| echo "Build mode: ${BUILD_MODE}" | |
| echo "Build type: ${BUILD_TYPE_EFFECTIVE}" | |
| - name: ccache | |
| uses: hendrikmuhs/ccache-action@f09c25b45002a07be2955cbe52e8cee55643f89d # v1.2.24 | |
| with: | |
| key: ${{ github.job }}-${{ matrix.os }}-${{ env.BUILD_TYPE }} | |
| - name: Install Qt6 | |
| uses: jurplel/install-qt-action@48d3ad6db93f3627c8ee7a0454bc6f3744f7e730 # v4.3.1 | |
| with: | |
| version: "6.10.3" | |
| cache: "true" | |
| - name: Install Dependence | |
| run: | | |
| brew install --formula automake texinfo libarchive googletest libsodium openssl@3 | |
| - name: Install Rust | |
| # Pinned to a SHA, so the @stable ref name no longer selects the | |
| # toolchain; say it explicitly instead. | |
| uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch | |
| with: | |
| toolchain: stable | |
| - name: Cache Cargo | |
| uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 | |
| with: | |
| workspaces: | | |
| rust -> build/cargo | |
| shared-key: rust-${{ runner.os }}-${{ matrix.os }}-${{ env.BUILD_TYPE }} | |
| cache-on-failure: true | |
| - name: Build GpgME | |
| run: | | |
| cd ${{github.workspace}}/third_party/gpgme | |
| export CC="ccache gcc" | |
| export CXX="ccache g++" | |
| ./autogen.sh | |
| mkdir -p build && cd build | |
| ../configure --enable-static \ | |
| --disable-shared \ | |
| --enable-silent-rules \ | |
| --disable-dependency-tracking \ | |
| --enable-languages=cl \ | |
| --disable-gpgconf-test \ | |
| --disable-gpg-test \ | |
| --disable-gpgsm-test \ | |
| --disable-g13-test | |
| make -j"$(sysctl -n hw.logicalcpu)" | |
| sudo make install | |
| ccache -s | |
| # Before anything is built: the entitlements the signing job will apply | |
| # must match the ones committed here, and must not have regained an | |
| # exception. Cheap, and it fails before a build is spent rather than | |
| # after one is signed. | |
| - name: Check Entitlements Are In Sync | |
| run: ${{github.workspace}}/scripts/check_entitlements_sync.sh | |
| - name: Build GpgFrontend | |
| run: | | |
| MACOS_MAJOR=$(sw_vers -productVersion | cut -d. -f1) | |
| MACOS_MINOR=$(sw_vers -productVersion | cut -d. -f2) | |
| if [[ "$MACOS_MAJOR" == "13" ]]; then | |
| DEPLOY_TARGET="13.0" | |
| elif [[ "$MACOS_MAJOR" == "14" ]]; then | |
| DEPLOY_TARGET="14.0" | |
| elif [[ "$MACOS_MAJOR" == "15" ]]; then | |
| DEPLOY_TARGET="15.0" | |
| elif [[ "$MACOS_MAJOR" == "26" ]]; then | |
| DEPLOY_TARGET="26.0" | |
| else | |
| DEPLOY_TARGET="${MACOS_MAJOR}.${MACOS_MINOR}" | |
| fi | |
| echo "Set MacOS Deployment Target: $DEPLOY_TARGET" | |
| # Homebrew's openssl@3 is keg-only, so it is not on the default search | |
| # path and find_package(OpenSSL) would pick up the unusable LibreSSL | |
| # headers that ship with macOS. vmime's TLS backend needs the real one. | |
| OPENSSL_ROOT="$(brew --prefix openssl@3)" | |
| echo "Using OpenSSL from: $OPENSSL_ROOT" | |
| # No signing identity, team id or provisioning profile is passed: this | |
| # job has none, by design. The bundle is signed in sign-macos. | |
| cmake -B ${{github.workspace}}/build -G Xcode \ | |
| -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} \ | |
| -DCMAKE_OSX_DEPLOYMENT_TARGET="${DEPLOY_TARGET}" \ | |
| -DGPGFRONTEND_BUILD_STABLE=${{env.GPGFRONTEND_BUILD_STABLE}} \ | |
| -DGPGFRONTEND_BUILD_PORTABLE=OFF \ | |
| -DOPENSSL_ROOT_DIR="${OPENSSL_ROOT}" \ | |
| -DGPGFRONTEND_LINK_GPGME_INTO_CORE=ON | |
| # DEVELOPMENT_TEAM and PROVISIONING_PROFILE_SPECIFIER are cleared as | |
| # well as the identity: src/CMakeLists.txt bakes a profile specifier | |
| # into the project, and Xcode would otherwise try to resolve a profile | |
| # this job has no business holding. | |
| cd ${{github.workspace}}/build/ | |
| xcodebuild -project ${{github.workspace}}/build/GpgFrontend.xcodeproj \ | |
| -scheme GpgFrontend \ | |
| -configuration "${{env.BUILD_TYPE}}" \ | |
| -archivePath ${{github.workspace}}/build/GpgFrontend.xcarchive \ | |
| archive \ | |
| CODE_SIGN_IDENTITY="" \ | |
| CODE_SIGNING_REQUIRED=NO \ | |
| CODE_SIGNING_ALLOWED=NO \ | |
| DEVELOPMENT_TEAM="" \ | |
| PROVISIONING_PROFILE_SPECIFIER="" | |
| # Skip -exportArchive: the generated ExportOptions.plist is | |
| # method=developer-id with signingStyle=manual, so exporting demands a | |
| # Developer ID identity in the keychain. Copy the app out of the | |
| # archive instead, exactly as mas-sandbox.yml does. | |
| mkdir -p ${{github.workspace}}/build/package | |
| cp -R ${{github.workspace}}/build/GpgFrontend.xcarchive/Products/Applications/GpgFrontend.app \ | |
| ${{github.workspace}}/build/package/ | |
| # The modules come from an install tree, like every other platform. | |
| # | |
| # This replaces a step that existed only to undo Xcode: `xcodebuild | |
| # archive` builds SKIP_INSTALL products into DerivedData and leaves a | |
| # SYMLINK where CMake asked for the file, so the module natives were | |
| # links into a path that existed on one runner during one job. They had | |
| # to be copied over by hand before anything could sign, ship or bind | |
| # them. | |
| # | |
| # `cmake --install` resolves that by construction -- it installs the | |
| # target's real file, not the link CMake left behind -- and then verifies | |
| # what it wrote. If it ever does not, the failure is exact rather than | |
| # mysterious: the installed-tree check refuses a symlinked entry native | |
| # by name and prints where it points. | |
| - name: Install The Module Tree (macOS) | |
| run: | | |
| set -euo pipefail | |
| STAGING="${{github.workspace}}/build/staging" | |
| rm -rf "$STAGING" | |
| cmake --install ${{github.workspace}}/build --prefix "$STAGING" | |
| # The same namespace layout a distribution gets. The bundle's two | |
| # roots are mapped from it below, which is the one macOS-specific | |
| # step Apple's layout genuinely requires. | |
| test -d "$STAGING/lib/gpgfrontend/modules" | |
| - name: Bundle Module Dependencies (macOS) | |
| run: | | |
| set -euo pipefail | |
| ${{github.workspace}}/scripts/bundle_macos_module_deps.sh \ | |
| --namespace-root ${{github.workspace}}/build/staging/lib/gpgfrontend/modules | |
| # Put the modules in the bundle. This is what makes macOS actually ship | |
| # them, and it is the last piece of §14.3. | |
| # | |
| # A namespace is split across two directories here and nowhere else, | |
| # because Apple wants data under Resources and executable code under | |
| # Frameworks: | |
| # | |
| # Contents/Resources/modules/<key>/module.gfmodule | |
| # Contents/Frameworks/GpgFrontendModules/<key>/lib*.dylib | |
| # | |
| # ModuleNativeRootFor() is the single place that knows those two belong | |
| # together, and this step is the first thing that exercises its macOS | |
| # branch against a real bundle rather than a synthetic path. | |
| # | |
| # No install_name_tool here: the rpaths a module needs are set at link | |
| # time (see ModuleRegistry.cmake), so this is a copy and nothing more. | |
| # Nothing re-signs either -- sign-macos signs every loose Mach-O under | |
| # Contents, these included, with the app's own identity, which is exactly | |
| # what Library Validation wants. | |
| - name: Place Modules In The Bundle (macOS) | |
| run: | | |
| set -euo pipefail | |
| APP="${{github.workspace}}/build/package/GpgFrontend.app" | |
| MODULE_ROOT="${{github.workspace}}/build/staging/lib/gpgfrontend/modules" | |
| DESC_ROOT="$APP/Contents/Resources/modules" | |
| # How many modules this build produced, from the list CMake writes | |
| # at configure time. Never a literal: a hardcoded count means adding | |
| # a fifth module breaks every gate at once, and the obvious repair is | |
| # to bump the number in each, which is how a gate stops checking. | |
| EXPECTED="$(${{github.workspace}}/scripts/build_info.sh ${{github.workspace}}/build module_count)" | |
| NATIVE_ROOT="$APP/Contents/Frameworks/GpgFrontendModules" | |
| test -d "$APP" | |
| rm -rf "$DESC_ROOT" "$NATIVE_ROOT" | |
| mkdir -p "$DESC_ROOT" "$NATIVE_ROOT" | |
| placed=0 | |
| for ns in "$MODULE_ROOT"/*/; do | |
| key="$(basename "$ns")" | |
| [ -f "$ns/module.gfmodule" ] || continue | |
| mkdir -p "$DESC_ROOT/$key" "$NATIVE_ROOT/$key" | |
| cp "$ns/module.gfmodule" "$DESC_ROOT/$key/" | |
| cp "$ns"native/*.dylib "$NATIVE_ROOT/$key/" | |
| echo " placed $key ($(ls -1 "$NATIVE_ROOT/$key" | wc -l | tr -d ' ') native file(s))" | |
| placed=$((placed + 1)) | |
| done | |
| test "$placed" -eq "$EXPECTED" || { | |
| echo "placed $placed namespaces, expected $EXPECTED" >&2 | |
| exit 1 | |
| } | |
| # Every module-shaped dylib in the bundle must be inside a namespace. | |
| # §16 item 11, done in shell because on macOS the two physical roots | |
| # differ and --assert-no-native-outside takes only one of them. | |
| STRAY="$(find "$APP/Contents" -name 'libgf_mod_*' \ | |
| ! -path "$NATIVE_ROOT/*" -print)" | |
| if [ -n "$STRAY" ]; then | |
| echo "module libraries outside any namespace in the bundle:" >&2 | |
| printf '%s\n' "$STRAY" >&2 | |
| exit 1 | |
| fi | |
| echo "--- what ships ---" | |
| find "$DESC_ROOT" "$NATIVE_ROOT" -type f | sed "s#^$APP/##" | sort | |
| - name: Deploy Qt | |
| run: | | |
| set -euo pipefail | |
| APP="${{github.workspace}}/build/package/GpgFrontend.app" | |
| # Every module native, named individually. | |
| # | |
| # macdeployqt deploys the Qt frameworks the APP links, and a module | |
| # may need frameworks the app never does -- QtConcurrent and QtXml, | |
| # here. Without this, those modules are placed in the bundle, pass | |
| # every descriptor check, and then fail to dlopen on a user's Mac | |
| # with "Library not loaded: @rpath/QtConcurrent.framework". Which is | |
| # exactly what happened. | |
| # | |
| # This is the same thing the other two platforms already do: | |
| # `-executable=` per module for linuxdeployqt, `--dir` per module for | |
| # windeployqt. macOS was the one leg missing it. | |
| # | |
| # The modules have to be INSIDE the bundle first -- macdeployqt | |
| # computes bundle-relative paths -- which is why placement moved | |
| # above this step. | |
| EXECUTABLES=() | |
| while IFS= read -r native; do | |
| EXECUTABLES+=("-executable=$native") | |
| done < <(find "$APP/Contents/Frameworks/GpgFrontendModules" \ | |
| -type f -name '*.dylib' | sort) | |
| test "${#EXECUTABLES[@]}" -gt 0 | |
| echo "deploying for ${#EXECUTABLES[@]} module native(s)" | |
| # No -codesign=: macdeployqt only ever signed what it copied itself. | |
| # sign-macos signs every code object explicitly, inside out. | |
| macdeployqt "$APP" \ | |
| -verbose=2 \ | |
| -always-overwrite \ | |
| "${EXECUTABLES[@]}" | |
| # Every module dependency must actually resolve inside the bundle, the | |
| # descriptors must still verify after deployment rewrote load commands, | |
| # the natives must pass the deployment audit, and every entry must still | |
| # carry its binding section. | |
| # | |
| # This is the check whose absence let a signed, notarized dmg ship with | |
| # three of its four modules unable to load. Everything else passed: | |
| # descriptors verified, natives resolved across the Resources/Frameworks | |
| # split, binding sections intact, codesign happy. The modules simply | |
| # referenced Qt frameworks the app does not link, so macdeployqt had | |
| # never deployed them, and dyld refused them at load. | |
| # | |
| # In a script rather than inline, because mas-sandbox.yml assembles the | |
| # same bundle from the same tree and needs the same four checks. Two | |
| # copies of a gate is how one of them stops matching the other. | |
| - name: Verify The Bundle's Modules (macOS) | |
| run: | | |
| set -euo pipefail | |
| # How many modules this build produced, from the list CMake writes at | |
| # configure time. Never a literal, and derived in every step that | |
| # needs it: a step's shell variables do not reach the next one. | |
| EXPECTED="$(${{github.workspace}}/scripts/build_info.sh ${{github.workspace}}/build module_count)" | |
| ${{github.workspace}}/scripts/verify_macos_bundle_modules.sh \ | |
| --app ${{github.workspace}}/build/package/GpgFrontend.app \ | |
| --namespace-root ${{github.workspace}}/build/staging/lib/gpgfrontend/modules \ | |
| --packager "$(${{github.workspace}}/scripts/build_info.sh ${{github.workspace}}/build module_packager)" \ | |
| --expect-count "$EXPECTED" | |
| - name: Stage Unsigned Payload | |
| run: | | |
| set -euo pipefail | |
| mkdir -p ${{github.workspace}}/build/unsigned | |
| # ditto, not zip: it is the only archiver that round-trips a bundle's | |
| # symlinks and permission bits through actions/upload-artifact intact. | |
| # | |
| # The app, plus the two files that dress the dmg window. sign-macos | |
| # is forbidden to take configuration, environment or signing policy | |
| # from this artifact -- no build-info file, no entitlements -- but | |
| # Finder dressing is inert: .DS_Store holds window bounds and icon | |
| # positions, the icns is the mounted volume's icon. Neither can reach | |
| # the signature or the entitlements. | |
| ditto -c -k --keepParent \ | |
| ${{github.workspace}}/build/package/GpgFrontend.app \ | |
| ${{github.workspace}}/build/unsigned/GpgFrontend.app.zip | |
| cp resource/lfs/dmg/DS_Store ${{github.workspace}}/build/unsigned/ | |
| # The volume icon is the app icon, as create-dmg's --volicon used to | |
| # be. Renamed on the way out rather than committed a third time: the | |
| # repository already carries this exact file twice. | |
| cp resource/lfs/icns/GpgFrontend.icns \ | |
| ${{github.workspace}}/build/unsigned/VolumeIcon.icns | |
| - name: Upload Unsigned Artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| # NOT "gpgfrontend-*": that is the pattern the release job downloads, | |
| # and an unsigned bundle must never reach a release. | |
| name: unsigned-macos-app-${{ matrix.os }} | |
| path: ${{github.workspace}}/build/unsigned/* | |
| # Separate and tiny, so it reaches macos-smoke and the provenance job | |
| # without passing through sign-macos. That job is forbidden to take | |
| # configuration from a build artifact, and this keeps it that way: it | |
| # never sees this file, and nothing about it changes. | |
| - name: Upload Build Info | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: buildinfo-${{ matrix.os }} | |
| path: ${{github.workspace}}/build/artifacts/build-info.json | |
| sign-macos: | |
| needs: build-macos | |
| # Two independent gates on which refs may ever request the signing key: this | |
| # condition, and the deployment branch rule on the environment below. Never | |
| # a pull request, never a branch other than main, never a non-v tag. | |
| if: >- | |
| github.event_name != 'pull_request' && | |
| (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) | |
| strategy: | |
| matrix: | |
| os: ["macos-15-intel", "macos-15", "macos-26-intel", "macos-26"] | |
| runs-on: ${{ matrix.os }} | |
| permissions: | |
| contents: read | |
| # All Apple signing material lives in this environment, not in repository | |
| # secrets. Maintainer: configure its deployment branch rule to allow only | |
| # `main` and tags matching `v*`, as defence in depth behind the `if:` above. | |
| environment: macos-signing | |
| steps: | |
| # Runs before anything else so a half-configured environment costs ten | |
| # seconds rather than a download, a full inside-out signing pass and a | |
| # notarization round trip. Only tests for emptiness; no value is printed, | |
| # and an unset secret arrives as the empty string. | |
| - name: Preflight Check Signing Credentials | |
| env: | |
| DEVELOP_ID_CERT: ${{ secrets.DEVELOP_ID_CERT }} | |
| DEVELOP_ID_CERT_PWD: ${{ secrets.DEVELOP_ID_CERT_PWD }} | |
| DEVELOPER_ID_CODE_SIGN_IDENTITY: ${{ secrets.DEVELOPER_ID_CODE_SIGN_IDENTITY }} | |
| ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} | |
| ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} | |
| run: | | |
| set -euo pipefail | |
| missing="" | |
| [ -n "${DEVELOP_ID_CERT:-}" ] || missing="$missing DEVELOP_ID_CERT" | |
| [ -n "${DEVELOP_ID_CERT_PWD:-}" ] || missing="$missing DEVELOP_ID_CERT_PWD" | |
| [ -n "${DEVELOPER_ID_CODE_SIGN_IDENTITY:-}" ] || missing="$missing DEVELOPER_ID_CODE_SIGN_IDENTITY" | |
| [ -n "${ASC_API_KEY_P8:-}" ] || missing="$missing ASC_API_KEY_P8" | |
| [ -n "${ASC_KEY_ID:-}" ] || missing="$missing ASC_KEY_ID" | |
| [ -n "${ASC_ISSUER_ID:-}" ] || missing="$missing ASC_ISSUER_ID" | |
| if [ -n "$missing" ]; then | |
| echo "The 'macos-signing' environment is missing:" >&2 | |
| for name in $missing; do echo " - $name" >&2; done | |
| echo >&2 | |
| echo "Set them under Settings > Environments > macos-signing." >&2 | |
| echo "The ASC_* trio comes from an App Store Connect API key:" >&2 | |
| echo " App Store Connect > Users and Access > Integrations >" >&2 | |
| echo " App Store Connect API > Team Keys > generate a key with the" >&2 | |
| echo " Developer role. ASC_API_KEY_P8 is the whole .p8 file including" >&2 | |
| echo " its BEGIN/END PRIVATE KEY lines (downloadable only once)," >&2 | |
| echo " ASC_KEY_ID is the Key ID column, ASC_ISSUER_ID the Issuer ID" >&2 | |
| echo " shown above the table." >&2 | |
| exit 1 | |
| fi | |
| echo "all six signing credentials are present" | |
| - name: Resolve Artifact Metadata | |
| id: meta | |
| env: | |
| # github.ref_type == 'tag' covers v* pushes; the dispatch input covers | |
| # a manual release build. Pull requests never reach this job. | |
| BUILD_TYPE_LOWER: ${{ (github.ref_type == 'tag' || github.event.inputs.build_mode == 'release') && 'release' || 'relwithdebinfo' }} | |
| run: | | |
| set -euo pipefail | |
| # Every name this job produces comes from GitHub context and runner | |
| # variables. Nothing is read back out of the downloaded artifact: | |
| # that would let the build side steer the privileged job. GITHUB_SHA | |
| # is set by the runner; eight hex digits is what `git rev-parse | |
| # --short HEAD` abbreviates to in this repository, so the macOS names | |
| # line up with the Linux and Windows artifacts. | |
| { | |
| echo "short_sha=${GITHUB_SHA:0:7}" | |
| echo "build_type_lower=${BUILD_TYPE_LOWER}" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Download Unsigned Bundle | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| name: unsigned-macos-app-${{ matrix.os }} | |
| path: ${{ runner.temp }}/unsigned | |
| - name: Unpack Unsigned Bundle | |
| run: | | |
| set -euo pipefail | |
| ditto -x -k "$RUNNER_TEMP/unsigned/GpgFrontend.app.zip" "$RUNNER_TEMP/app" | |
| test -d "$RUNNER_TEMP/app/GpgFrontend.app" | |
| - name: Write Signing Policy And Helpers | |
| run: | | |
| set -euo pipefail | |
| # --- Entitlement policy ------------------------------------------- | |
| # A verbatim copy of resource/entitlements/Normal.entitlements, and | |
| # deliberately a copy: reading it from the build artifact would let a | |
| # compromised build job pick its own entitlements and then verify them | |
| # against its own choice. Keep the two in sync by hand. | |
| cat > "$RUNNER_TEMP/entitlements.plist" <<'PLIST' | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"> | |
| <dict> | |
| <!-- disable-library-validation is deliberately absent; see | |
| resource/entitlements/Normal.entitlements. --> | |
| <key>com.apple.security.cs.allow-jit</key> | |
| <true/> | |
| <key>com.apple.security.cs.allow-unsigned-executable-memory</key> | |
| <true/> | |
| </dict> | |
| </plist> | |
| PLIST | |
| # --- Explicit inside-out signer ------------------------------------ | |
| # A `run:` step is its own shell process, so a function defined in one | |
| # step is invisible in the next; the helpers go on disk instead. The | |
| # heredocs are quoted, so nothing expands at write time and no secret | |
| # ever lands in a script file -- they read credentials from the | |
| # environment of the step that calls them. | |
| cat > "$RUNNER_TEMP/sign-bundle.sh" <<'SH' | |
| #!/bin/bash | |
| set -euo pipefail | |
| APP="$1" | |
| ENTITLEMENTS="$2" | |
| : "${IDENTITY:?IDENTITY must be set}" | |
| # `--timestamp` is a network round trip to timestamp.apple.com for | |
| # every single object, and that service throttles and occasionally | |
| # just fails: | |
| # | |
| # libgf_sdk.2.2.2.dylib: A timestamp was expected but was not found | |
| # | |
| # A bundle this size signs around a hundred objects, and shipping the | |
| # modules added six more, which is what made a pre-existing fragility | |
| # start showing up. Signing is idempotent under --force, so a retry is | |
| # safe. | |
| # | |
| # Only timestamp-shaped failures are retried. A wrong identity, a | |
| # locked keychain or a malformed object must still fail on the first | |
| # attempt and say so -- retrying those would turn a clear error into a | |
| # slow one. | |
| codesign_retrying() { | |
| local attempt=1 | |
| local output | |
| while [ "$attempt" -le 5 ]; do | |
| if output="$(codesign "$@" 2>&1)"; then | |
| [ -n "$output" ] && printf '%s\n' "$output" | |
| return 0 | |
| fi | |
| case "$output" in | |
| *timestamp*|*Timestamp*|*"network connection"*|*"service is not available"*|*"Connection refused"*) | |
| echo " timestamp service failed (attempt $attempt/5), retrying:" >&2 | |
| printf ' %s\n' "$output" >&2 | |
| sleep $((attempt * 5)) | |
| attempt=$((attempt + 1)) | |
| ;; | |
| *) | |
| printf '%s\n' "$output" >&2 | |
| return 1 | |
| ;; | |
| esac | |
| done | |
| echo "codesign could not obtain a timestamp after 5 attempts:" >&2 | |
| printf '%s\n' "$output" >&2 | |
| return 1 | |
| } | |
| sign_inner() { # nested code: hardened runtime, never entitlements | |
| codesign_retrying --force --timestamp --options=runtime \ | |
| --sign "$IDENTITY" "$1" | |
| } | |
| # Gate: fail closed on any executable bundle type this script does not | |
| # handle deliberately. Two nets -- known bundle suffixes, and the | |
| # structural giveaway of a Contents/MacOS directory anywhere but the | |
| # app root. This is what stops the loose Mach-O pass from signing the | |
| # executable inside an unknown bundle while leaving that bundle | |
| # unsigned. If GpgFrontend ever gains an XPC service, an appex, a | |
| # helper app or a plugin bundle, its signing and entitlements get | |
| # added here on purpose. | |
| UNEXPECTED="$(mktemp)" | |
| { | |
| find "$APP/Contents" -type d \ | |
| \( -name '*.app' -o -name '*.appex' -o -name '*.xpc' \ | |
| -o -name '*.bundle' -o -name '*.systemextension' \ | |
| -o -name '*.pluginkit' -o -name '*.qlgenerator' \) | |
| find "$APP/Contents" -type d -name 'MacOS' ! -path "$APP/Contents/MacOS" | |
| } > "$UNEXPECTED" | |
| if [ -s "$UNEXPECTED" ]; then | |
| echo "unexpected nested code bundle(s); extend sign-bundle.sh deliberately:" >&2 | |
| cat "$UNEXPECTED" >&2 | |
| exit 1 | |
| fi | |
| rm -f "$UNEXPECTED" | |
| # Pass 1: loose Mach-O files outside any framework -- the gf_* dylibs | |
| # Xcode embeds via XCODE_EMBED_FRAMEWORKS, the gf_mod_* modules from | |
| # XCODE_EMBED_PLUGINS, and every Qt plugin and third-party dylib | |
| # macdeployqt copied in. | |
| # | |
| # NB: `[ x ] && continue` would abort the loop under `set -e` whenever | |
| # the test is false, so the skips are written as full if-blocks. | |
| find "$APP/Contents" -type f ! -path '*.framework/*' | while IFS= read -r f; do | |
| if [ "$f" = "$APP/Contents/MacOS/GpgFrontend" ]; then | |
| continue | |
| fi | |
| if file -b "$f" | grep -q 'Mach-O'; then | |
| sign_inner "$f" | |
| fi | |
| done | |
| # Pass 2: framework bundles, signed at the .framework path. The | |
| # Versions/A plus symlink layout is the normal shape of a macOS | |
| # framework, not a reason to sign a subdirectory. Nested code a | |
| # framework carries of its own is signed first; its own principal | |
| # executable is not signed separately, because the bundle signature | |
| # is what covers it. | |
| sign_framework() { | |
| fw="$1" | |
| name="$(basename "$fw" .framework)" | |
| find "$fw" -type f | while IFS= read -r f; do | |
| case "$f" in | |
| "$fw"/*.framework/*) continue ;; # deeper framework, done | |
| "$fw"/Versions/*/"$name") continue ;; # versioned framework binary | |
| "$fw"/"$name") continue ;; # flat framework binary | |
| esac | |
| if file -b "$f" | grep -q 'Mach-O'; then | |
| sign_inner "$f" | |
| fi | |
| done | |
| sign_inner "$fw" | |
| } | |
| # -d walks depth first, so an inner framework is fully signed before | |
| # the one that contains it. | |
| find -d "$APP/Contents" -type d -name '*.framework' | while IFS= read -r fw; do | |
| sign_framework "$fw" | |
| done | |
| # Pass 3: the application itself, last, and the only thing that gets | |
| # entitlements. Through the same retry: the outer signature needs a | |
| # timestamp exactly as much as the inner ones, and it is the single | |
| # call whose loss wastes the whole job. | |
| codesign_retrying --force --timestamp --options=runtime \ | |
| --entitlements "$ENTITLEMENTS" --sign "$IDENTITY" "$APP" | |
| SH | |
| # --- Notarization --------------------------------------------------- | |
| # Submits and gates on Accepted. It deliberately does not staple: | |
| # stapler cannot staple a .zip (that is only a transport for | |
| # notarytool), so stapling belongs at the call sites, which know | |
| # whether they hold a bundle or a disk image. | |
| cat > "$RUNNER_TEMP/notarize.sh" <<'SH' | |
| #!/bin/bash | |
| set -euo pipefail | |
| TARGET="$1" | |
| # Guard again here: this script is what actually spends the | |
| # credential, and a clear message beats "parameter null or not set". | |
| : "${ASC_API_KEY_P8:?not set - add it to the macos-signing environment}" | |
| : "${ASC_KEY_ID:?not set - add it to the macos-signing environment}" | |
| : "${ASC_ISSUER_ID:?not set - add it to the macos-signing environment}" | |
| KEY_PATH="$RUNNER_TEMP/asc_api_key.p8" | |
| RESULT="$RUNNER_TEMP/notary-result.json" | |
| # The key exists on disk only for the length of this one invocation. | |
| trap 'rm -f "$KEY_PATH"' EXIT | |
| umask 077 | |
| printf '%s\n' "$ASC_API_KEY_P8" > "$KEY_PATH" | |
| xcrun notarytool submit "$TARGET" \ | |
| --key "$KEY_PATH" --key-id "$ASC_KEY_ID" --issuer "$ASC_ISSUER_ID" \ | |
| --wait --output-format json > "$RESULT" | |
| # --wait exits 0 even when the verdict is Invalid, so read the verdict | |
| # back explicitly rather than trusting the exit status. | |
| STATUS="$(plutil -extract status raw -o - "$RESULT")" | |
| if [ "$STATUS" != "Accepted" ]; then | |
| SUBMISSION_ID="$(plutil -extract id raw -o - "$RESULT")" | |
| echo "notarization of $(basename "$TARGET") returned: $STATUS" >&2 | |
| xcrun notarytool log "$SUBMISSION_ID" \ | |
| --key "$KEY_PATH" --key-id "$ASC_KEY_ID" --issuer "$ASC_ISSUER_ID" >&2 || true | |
| exit 1 | |
| fi | |
| echo "notarization accepted: $(basename "$TARGET")" | |
| SH | |
| # --- Entitlement equality check ------------------------------------- | |
| cat > "$RUNNER_TEMP/check-entitlements.py" <<'PY' | |
| #!/usr/bin/env python3 | |
| """Assert the signed bundle carries exactly the entitlements we asked for.""" | |
| import plistlib | |
| import sys | |
| expected_path, actual_path = sys.argv[1], sys.argv[2] | |
| with open(expected_path, "rb") as f: | |
| expected = plistlib.load(f) | |
| with open(actual_path, "rb") as f: | |
| actual = plistlib.load(f) | |
| if expected == actual: | |
| print("entitlements match policy (%d keys)" % len(expected)) | |
| sys.exit(0) | |
| for k in sorted(set(expected) - set(actual)): | |
| print("missing entitlement: %s = %r" % (k, expected[k]), file=sys.stderr) | |
| for k in sorted(set(actual) - set(expected)): | |
| print("unexpected entitlement: %s = %r" % (k, actual[k]), file=sys.stderr) | |
| for k in sorted(set(expected) & set(actual)): | |
| if expected[k] != actual[k]: | |
| print("changed entitlement: %s: expected %r, got %r" | |
| % (k, expected[k], actual[k]), file=sys.stderr) | |
| sys.exit(1) | |
| PY | |
| chmod 700 "$RUNNER_TEMP/sign-bundle.sh" "$RUNNER_TEMP/notarize.sh" | |
| - name: Prepare Signing Keychain | |
| env: | |
| DEVELOP_ID_CERT: ${{ secrets.DEVELOP_ID_CERT }} | |
| DEVELOP_ID_CERT_PWD: ${{ secrets.DEVELOP_ID_CERT_PWD }} | |
| run: | | |
| set -euo pipefail | |
| KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" | |
| # Random per run: a hardcoded keychain password is a credential | |
| # checked into the repository. | |
| KEYCHAIN_PWD="$(openssl rand -hex 24)" | |
| CERT_PATH="$RUNNER_TEMP/certificate.p12" | |
| umask 077 | |
| printf '%s' "$DEVELOP_ID_CERT" | base64 --decode -o "$CERT_PATH" | |
| security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" | |
| security set-keychain-settings -lut 3600 "$KEYCHAIN_PATH" | |
| security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" | |
| security import "$CERT_PATH" -k "$KEYCHAIN_PATH" \ | |
| -P "$DEVELOP_ID_CERT_PWD" -t cert -f pkcs12 -T /usr/bin/codesign | |
| # The private key is in the keychain now; the file is not needed again. | |
| rm -f "$CERT_PATH" | |
| # Without this codesign blocks on a UI prompt no runner can answer. | |
| security set-key-partition-list -S apple-tool:,apple:,codesign: \ | |
| -s -k "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" > /dev/null | |
| # Prepend rather than replace: the Apple intermediate CAs live in the | |
| # system keychain and codesign must still find them. `security | |
| # list-keychains` prints one indented, double-quoted path per line; | |
| # strip only the indent and the two surrounding quotes, and collect | |
| # into the positional parameters (macOS bash is 3.2, no mapfile). A | |
| # here-doc rather than a pipe, so `set --` runs in this shell. | |
| set -- | |
| while IFS= read -r line; do | |
| kc=$(printf '%s\n' "$line" | sed -e 's/^[[:space:]]*"//' -e 's/"[[:space:]]*$//') | |
| if [ -n "$kc" ]; then | |
| set -- "$@" "$kc" | |
| fi | |
| done <<EOF | |
| $(security list-keychains -d user) | |
| EOF | |
| security list-keychains -d user -s "$KEYCHAIN_PATH" "$@" | |
| # The keychain stays unlocked and in the search list, so no later step | |
| # needs the password -- which is why it never leaves this step. | |
| - name: Sign Application | |
| env: | |
| IDENTITY: ${{ secrets.DEVELOPER_ID_CODE_SIGN_IDENTITY }} | |
| run: | | |
| set -euo pipefail | |
| "$RUNNER_TEMP/sign-bundle.sh" "$RUNNER_TEMP/app/GpgFrontend.app" \ | |
| "$RUNNER_TEMP/entitlements.plist" | |
| # --deep survives here for verification only, where recursing over | |
| # everything is exactly what is wanted. It never signs. | |
| codesign --verify --deep --strict --verbose=4 \ | |
| "$RUNNER_TEMP/app/GpgFrontend.app" | |
| - name: Verify Embedded Entitlements | |
| run: | | |
| set -euo pipefail | |
| APP="$RUNNER_TEMP/app/GpgFrontend.app" | |
| codesign -d --entitlements - --xml "$APP" \ | |
| > "$RUNNER_TEMP/actual.entitlements" 2>/dev/null | |
| # Equality in both directions: an entitlement that appears only in the | |
| # signed result is as much a defect as a missing one. | |
| python3 "$RUNNER_TEMP/check-entitlements.py" \ | |
| "$RUNNER_TEMP/entitlements.plist" "$RUNNER_TEMP/actual.entitlements" | |
| # Entitlements are inert without the hardened runtime, and that is a | |
| # property of the signature, not of the entitlement dictionary. | |
| if ! codesign -d --verbose=2 "$APP" 2>&1 | grep -q 'flags=.*runtime'; then | |
| echo "hardened runtime is not enabled on the signed app" >&2 | |
| exit 1 | |
| fi | |
| # Named explicitly, not left to the equality check above. | |
| # | |
| # That check compares the signed result against the expected plist in | |
| # both directions, so it already refuses a stray entitlement -- but it | |
| # would pass just as happily if someone re-added this one to BOTH | |
| # files. This says out loud which entitlement must never come back. | |
| # | |
| # It was needed while module code lived outside the bundle, where | |
| # Apple could not sign it. Modules now ship inside | |
| # Contents/Frameworks, signed with the app's own identity, which is | |
| # precisely the case Library Validation exists to permit. If modules | |
| # stop loading, the fix is a module signed with the wrong Team ID -- | |
| # not this exception. | |
| if grep -q 'disable-library-validation' "$RUNNER_TEMP/actual.entitlements"; then | |
| echo "the signed app carries disable-library-validation;" >&2 | |
| echo "module code is signed with the app's identity and does not" >&2 | |
| echo "need it -- see resource/entitlements/Normal.entitlements" >&2 | |
| exit 1 | |
| fi | |
| echo "library validation is ON: no disable-library-validation entitlement" | |
| - name: Notarize And Staple Application | |
| env: | |
| ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} | |
| ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} | |
| SHORT_SHA: ${{ steps.meta.outputs.short_sha }} | |
| OS_IDENTIFIER: ${{ matrix.os }} | |
| run: | | |
| set -euo pipefail | |
| # notarytool only accepts an archive, so the bundle travels as a | |
| # throwaway zip; the ticket is issued against the bundle, so the | |
| # bundle is what gets stapled. | |
| ditto -c -k --keepParent "$RUNNER_TEMP/app/GpgFrontend.app" \ | |
| "$RUNNER_TEMP/notarize-app.zip" | |
| "$RUNNER_TEMP/notarize.sh" "$RUNNER_TEMP/notarize-app.zip" | |
| xcrun stapler staple "$RUNNER_TEMP/app/GpgFrontend.app" | |
| xcrun stapler validate "$RUNNER_TEMP/app/GpgFrontend.app" | |
| rm -f "$RUNNER_TEMP/notarize-app.zip" | |
| # The .app is stapled but not shipped on its own: the dmg is the only | |
| # deliverable. Stapling it still matters, because the ticket travels | |
| # inside the dmg -- once a user drags the app to /Applications the | |
| # dmg's own staple no longer covers it, and without this the first | |
| # launch would need a network round trip to Apple. | |
| - name: Build Notarize And Staple Disk Image | |
| env: | |
| IDENTITY: ${{ secrets.DEVELOPER_ID_CODE_SIGN_IDENTITY }} | |
| ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} | |
| ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} | |
| SHORT_SHA: ${{ steps.meta.outputs.short_sha }} | |
| OS_IDENTIFIER: ${{ matrix.os }} | |
| run: | | |
| set -euo pipefail | |
| OUT="$RUNNER_TEMP/upload-artifact" | |
| mkdir -p "$OUT" | |
| DMG="$OUT/GpgFrontend-${OS_IDENTIFIER}-${SHORT_SHA}.dmg" | |
| # hdiutil rather than create-dmg: this job runs Apple tooling only. | |
| # create-dmg drove Finder over AppleScript to place the icons, which | |
| # is exactly the part that flakes on a headless runner. The committed | |
| # .DS_Store carries the same window bounds and icon positions, so the | |
| # layout is reproduced without Finder ever being involved. | |
| STAGE="$RUNNER_TEMP/dmg-root" | |
| mkdir -p "$STAGE" | |
| ditto "$RUNNER_TEMP/app/GpgFrontend.app" "$STAGE/GpgFrontend.app" | |
| ln -s /Applications "$STAGE/Applications" | |
| cp "$RUNNER_TEMP/unsigned/DS_Store" "$STAGE/.DS_Store" | |
| cp "$RUNNER_TEMP/unsigned/VolumeIcon.icns" "$STAGE/.VolumeIcon.icns" | |
| # A read/write image first: the volume's custom-icon bit can only be | |
| # set on a mounted volume, and hdiutil cannot set it from -srcfolder. | |
| RW="$RUNNER_TEMP/rw.dmg" | |
| MNT="$RUNNER_TEMP/dmg-mnt" | |
| # -fs HFS+ is deliberate. Left to itself hdiutil now builds an APFS | |
| # image, which attaches as a nested container (a synthesised APFS | |
| # disk inside the image's own disk) -- detaching that by mount point | |
| # unmounts the volume but fails to eject the image, leaving the | |
| # convert step reading a still-attached file. HFS+ is also the | |
| # traditional read-only app-image format, mountable everywhere, and | |
| # what create-dmg produced before. | |
| hdiutil create -format UDRW -fs HFS+ -volname GpgFrontend \ | |
| -srcfolder "$STAGE" -ov "$RW" | |
| # Detach by device node, not by mount point: the first line of | |
| # `hdiutil attach` output is the image's top-level device, and that | |
| # is the only handle that reliably ejects the whole image. | |
| ATTACH_OUT="$(hdiutil attach "$RW" -nobrowse -noautoopen -readwrite -noverify -mountpoint "$MNT")" | |
| printf '%s\n' "$ATTACH_OUT" | |
| DEV="$(printf '%s\n' "$ATTACH_OUT" | awk 'NR==1 {print $1}')" | |
| # Without the custom-icon attribute Finder ignores .VolumeIcon.icns. | |
| # SetFile ships with the Xcode command line tools and is deprecated, | |
| # so warn rather than fail if a future runner image drops it. | |
| if command -v SetFile > /dev/null 2>&1; then | |
| SetFile -a C "$MNT" | |
| else | |
| echo "::warning::SetFile not found; dmg volume icon not applied" | |
| fi | |
| # Spotlight or a volume scan can hold the image briefly after | |
| # SetFile, so retry before resorting to -force. | |
| for attempt in 1 2 3 4 5; do | |
| if hdiutil detach "$DEV" > /dev/null 2>&1; then | |
| DEV="" | |
| break | |
| fi | |
| echo "detach attempt ${attempt} failed; retrying" | |
| sleep 3 | |
| done | |
| if [ -n "$DEV" ]; then | |
| hdiutil detach "$DEV" -force | |
| fi | |
| hdiutil convert "$RW" -format UDZO -o "$DMG" -ov | |
| rm -f "$RW" | |
| # Same timestamp exposure as every other signature, and by this point | |
| # the app is built, signed, notarized and stapled -- so losing the | |
| # job to a throttled timestamp service here is the most expensive | |
| # place for it to happen. Retried inline rather than through | |
| # sign-bundle.sh's helper, which is not sourced in this step. | |
| for attempt in 1 2 3 4 5; do | |
| if codesign --force --timestamp --sign "$IDENTITY" "$DMG"; then | |
| break | |
| fi | |
| if [ "$attempt" -eq 5 ]; then | |
| echo "could not sign the disk image after 5 attempts" >&2 | |
| exit 1 | |
| fi | |
| echo "dmg signing failed (attempt $attempt/5), retrying" >&2 | |
| sleep $((attempt * 5)) | |
| done | |
| "$RUNNER_TEMP/notarize.sh" "$DMG" | |
| xcrun stapler staple "$DMG" | |
| xcrun stapler validate "$DMG" | |
| - name: Assess With Gatekeeper | |
| env: | |
| SHORT_SHA: ${{ steps.meta.outputs.short_sha }} | |
| OS_IDENTIFIER: ${{ matrix.os }} | |
| run: | | |
| set -euo pipefail | |
| # The acceptance test: what macOS itself decides about the finished | |
| # deliverables, not just what codesign says about their structure. | |
| # --type execute is the assessment performed when the app is launched. | |
| spctl --assess --type execute --verbose=4 \ | |
| "$RUNNER_TEMP/app/GpgFrontend.app" | |
| # --type open with the primary-signature context is what Finder | |
| # performs when the downloaded disk image is mounted. | |
| spctl --assess --type open --context context:primary-signature --verbose=4 \ | |
| "$RUNNER_TEMP/upload-artifact/GpgFrontend-${OS_IDENTIFIER}-${SHORT_SHA}.dmg" | |
| - name: Upload Artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: gpgfrontend-${{ matrix.os }}-installed-${{ steps.meta.outputs.build_type_lower }}-${{ steps.meta.outputs.short_sha }} | |
| path: ${{ runner.temp }}/upload-artifact/* | |
| - name: Clean Up Signing Material | |
| if: always() | |
| run: | | |
| security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" || true | |
| rm -f "$RUNNER_TEMP/asc_api_key.p8" \ | |
| "$RUNNER_TEMP/certificate.p12" \ | |
| "$RUNNER_TEMP/sign-bundle.sh" \ | |
| "$RUNNER_TEMP/notarize.sh" \ | |
| "$RUNNER_TEMP/check-entitlements.py" \ | |
| "$RUNNER_TEMP/notary-result.json" | |
| # Does the thing we ship actually work. | |
| # | |
| # Every other macOS check asks whether the bundle is AUTHENTIC: descriptors | |
| # verify, natives resolve, binding sections survive, codesign is happy. None | |
| # of them asks whether it RUNS. That gap shipped a notarized dmg in which | |
| # three of four modules could not load, and the only reason it was caught is | |
| # that a person opened it and read the log. | |
| # | |
| # So this opens it and reads the log. It is deliberately the dmg from | |
| # sign-macos -- signed, notarized, stapled -- and not the bundle build-macos | |
| # produced, because every step between those two can break loading and none | |
| # of them is exercised by looking at the earlier one. | |
| # | |
| # Unprivileged: no Apple credentials, nothing to sign. It only consumes. | |
| macos-smoke: | |
| needs: sign-macos | |
| if: >- | |
| github.event_name != 'pull_request' && | |
| (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) | |
| strategy: | |
| # Same legs as sign-macos: an arm64 bundle cannot be smoke-tested on an | |
| # Intel runner, and a module that loads on one may not on the other. | |
| fail-fast: false | |
| matrix: | |
| os: ["macos-15-intel", "macos-15", "macos-26-intel", "macos-26"] | |
| runs-on: ${{ matrix.os }} | |
| permissions: | |
| contents: read | |
| steps: | |
| # For scripts/check_module_status.py and nothing else. This job is an | |
| # unprivileged consumer: it holds no credentials and signs nothing, so a | |
| # checkout costs it no trust the way one would in sign-macos. | |
| - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 | |
| with: | |
| lfs: "false" | |
| - name: Download Build Info | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| name: buildinfo-${{ matrix.os }} | |
| path: buildinfo/ | |
| - name: Download Signed Artifact | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| path: signed/ | |
| pattern: gpgfrontend-${{ matrix.os }}-installed-* | |
| merge-multiple: true | |
| - name: Mount The Disk Image | |
| run: | | |
| set -euo pipefail | |
| DMG="$(find signed -name '*.dmg' | head -1)" | |
| test -n "$DMG" || { echo "no dmg in the artifact" >&2; ls -R signed >&2; exit 1; } | |
| echo "DMG=$DMG" >> "$GITHUB_ENV" | |
| MNT="$RUNNER_TEMP/dmg" | |
| hdiutil attach "$DMG" -nobrowse -noautoopen -readonly -mountpoint "$MNT" | |
| # Copied OUT of the read-only image: the app writes nothing, but a | |
| # mounted dmg is a strange place to launch from and `ditto` keeps the | |
| # signature intact where `cp -r` does not. | |
| ditto "$MNT/GpgFrontend.app" "$RUNNER_TEMP/GpgFrontend.app" | |
| hdiutil detach "$MNT" | |
| echo "APP=$RUNNER_TEMP/GpgFrontend.app" >> "$GITHUB_ENV" | |
| - name: Assess The Signature As macOS Would | |
| run: | | |
| set -euo pipefail | |
| # --deep because the modules are nested code: a shallow verify would | |
| # pass on a bundle whose module dylibs are unsigned or mis-signed, | |
| # which is the failure Library Validation exists to catch. | |
| codesign --verify --deep --strict --verbose=2 "$APP" | |
| # What Gatekeeper decides when the app is launched. | |
| spctl --assess --type execute --verbose=4 "$APP" | |
| # The app and every module must share a Team ID. This is the whole | |
| # basis on which Library Validation permits loading them, so it is | |
| # asserted rather than assumed. | |
| APP_TEAM="$(codesign -dv --verbose=4 "$APP" 2>&1 \ | |
| | sed -n 's/^TeamIdentifier=//p')" | |
| test -n "$APP_TEAM" && test "$APP_TEAM" != "not set" | |
| echo "app team identifier: $APP_TEAM" | |
| while IFS= read -r dylib; do | |
| team="$(codesign -dv --verbose=4 "$dylib" 2>&1 \ | |
| | sed -n 's/^TeamIdentifier=//p')" | |
| if [ "$team" != "$APP_TEAM" ]; then | |
| echo " FAIL $(basename "$dylib") is team \"$team\", app is \"$APP_TEAM\"" >&2 | |
| exit 1 | |
| fi | |
| echo " ok $(basename "$dylib")" | |
| done < <(find "$APP/Contents/Frameworks/GpgFrontendModules" \ | |
| -type f -name '*.dylib' | sort) | |
| # §15/§16: the exception must not have come back. | |
| if codesign -d --entitlements - --xml "$APP" 2>/dev/null \ | |
| | grep -q 'disable-library-validation'; then | |
| echo "the shipped app carries disable-library-validation" >&2 | |
| exit 1 | |
| fi | |
| echo "library validation is ON" | |
| - name: Launch It And Count The Modules | |
| run: | | |
| set -euo pipefail | |
| # How many modules this build produced, from the build system that | |
| # decided it. This job has no build tree, so the figure is downloaded | |
| # rather than derived -- and it comes from build-info.json, not from | |
| # a provenance record: what this gate needs is one number about the | |
| # build, and coupling it to the release attestation machinery was the | |
| # only thing keeping that machinery inside the pipeline. | |
| # | |
| # Counting the descriptors inside the bundle instead would be | |
| # circular: a module that never made it into the bundle would lower | |
| # both the expectation and the result, and the check would pass. | |
| INFO="$(find buildinfo -name 'build-info.json' | head -1)" | |
| test -n "$INFO" || { echo "no build-info.json for this leg" >&2; exit 1; } | |
| EXPECTED="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["module_count"])' "$INFO")" | |
| echo "this build produced $EXPECTED module(s)" | |
| # An isolated HOME, so this touches no runner state and starts from a | |
| # profile that has never existed. | |
| export HOME="$RUNNER_TEMP/smoke-home" | |
| mkdir -p "$HOME" | |
| # Offscreen only if it is actually in the bundle. | |
| # | |
| # macdeployqt deploys the platform plugins the app uses, which on | |
| # macOS is `cocoa` alone -- asking unconditionally for `offscreen` | |
| # aborts before main() with "no Qt platform plugin could be | |
| # initialized", which reads like a broken build and is not one. | |
| # | |
| # And it cannot simply be supplied: library validation is ON as of | |
| # §18n, so a plugin signed by anyone but us would be refused, and | |
| # copying one INTO the bundle breaks the very signature this job | |
| # verified a step earlier. Either it ships or it is not used. | |
| # | |
| # `cocoa` is the fallback and works: a GitHub macOS runner has a | |
| # window session. The status option exits as soon as it has written | |
| # the report, so nothing is left waiting on a window. | |
| if [ -f "$APP/Contents/PlugIns/platforms/libqoffscreen.dylib" ]; then | |
| export QT_QPA_PLATFORM=offscreen | |
| else | |
| echo "no offscreen plugin in the bundle; using the default platform" | |
| fi | |
| STATUS="$RUNNER_TEMP/module-status.json" | |
| rc=0 | |
| "$APP/Contents/MacOS/GpgFrontend" --module-status "$STATUS" \ | |
| >"$RUNNER_TEMP/smoke-stdout.log" 2>&1 || rc=$? | |
| if [ ! -f "$STATUS" ]; then | |
| echo "the signed app produced no status report (exit $rc)" >&2 | |
| echo "--- stdout ---" >&2 | |
| cat "$RUNNER_TEMP/smoke-stdout.log" >&2 || true | |
| exit 1 | |
| fi | |
| cat "$STATUS" | |
| ./scripts/check_module_status.py "$STATUS" "$EXPECTED" | |
| test "$rc" -eq 0 || { echo "the app exited $rc" >&2; exit 1; } | |
| # Public build provenance, and the ONLY job in this workflow with | |
| # `id-token: write`. | |
| # | |
| # Keyless Sigstore: Fulcio issues a short-lived certificate bound to this | |
| # job's OIDC identity, the signature and certificate go into the public Rekor | |
| # transparency log, and the private key exists for seconds and is never | |
| # written down. There is no signing secret here to steal or rotate. | |
| # | |
| # What this establishes and what it does not, stated plainly because the two | |
| # are easy to conflate: | |
| # | |
| # it DOES prove these exact bytes were produced by this workflow, at | |
| # this commit, in this repository, and say so in a public log | |
| # anyone can query without asking us | |
| # | |
| # it does NOT authenticate a module descriptor, replace Apple code signing | |
| # or Windows Authenticode, or participate in module loading at | |
| # all. Runtime verification is offline and depends on none of | |
| # this: a user with no network still gets every guarantee the | |
| # Host makes about its modules. | |
| # | |
| # Least privilege is the reason this is a separate job rather than a step in | |
| # `release`: `release` needs `contents: write` to publish, and nothing should | |
| # hold both the ability to mint an identity token and the ability to rewrite | |
| # the repository. | |
| provenance: | |
| needs: [build, sign-macos] | |
| runs-on: ubuntu-latest | |
| if: >- | |
| github.event_name == 'push' && github.ref == 'refs/heads/main' && | |
| needs.sign-macos.result == 'success' | |
| permissions: | |
| # The identity token Fulcio exchanges for a certificate. Nothing else. | |
| id-token: write | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 | |
| with: | |
| lfs: "false" | |
| - name: Download Artifacts | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| path: artifacts/ | |
| pattern: gpgfrontend-* | |
| - name: Download Build Info | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| path: buildinfo/ | |
| pattern: buildinfo-* | |
| # Records are written HERE, over the files that are actually going to be | |
| # published, and nowhere else. | |
| # | |
| # They used to be written by each build and signing job, which put the | |
| # release attestation machinery inside the build and package path: five | |
| # steps across three jobs, a two-stage unsigned/signed protocol, and a | |
| # smoke test that took its module count from a provenance record because | |
| # that was the only file it had. None of it was needed to build or | |
| # package GpgFrontend, and a downstream packager had to understand it | |
| # anyway. | |
| # | |
| # The two-stage protocol goes with it. It existed because a record | |
| # written before signing describes bytes signing then changed; a record | |
| # written over the final artifact cannot have that problem. | |
| - name: Write Build Records | |
| run: | | |
| set -euo pipefail | |
| ./scripts/write_build_records.sh \ | |
| --artifacts artifacts/ \ | |
| --build-info buildinfo/ \ | |
| --out records/ | |
| - name: Install cosign | |
| uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 | |
| - name: Sign Deliverables | |
| run: | | |
| set -euo pipefail | |
| mkdir -p provenance | |
| # Every deliverable, plus the record that describes it. The record is | |
| # signed too: an attestation over the binaries that said nothing | |
| # about which build produced them would leave the interesting | |
| # question unanswered. | |
| COUNT=0 | |
| while read -r artifact; do | |
| name="$(basename "$artifact")" | |
| leg="$(basename "$(dirname "$artifact")")" | |
| bundle="provenance/${leg}--${name}.sigstore.json" | |
| COSIGN_EXPERIMENTAL=1 cosign sign-blob \ | |
| --yes \ | |
| --bundle "$bundle" \ | |
| "$artifact" | |
| echo "signed ${leg}/${name}" | |
| COUNT=$((COUNT + 1)) | |
| done < <(find artifacts/ records/ -type f \ | |
| \( -name 'GpgFrontend-*' -o -name 'build-record-*.json' \) \ | |
| | sort) | |
| test "$COUNT" -gt 0 || { echo "nothing was signed"; exit 1; } | |
| echo "$COUNT blob(s) signed" | |
| - name: Upload Build Records | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: gpgfrontend-build-records | |
| path: records/* | |
| - name: Upload Provenance Bundles | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: gpgfrontend-provenance | |
| path: provenance/* | |
| release: | |
| needs: [build, sign-macos, macos-smoke, provenance] | |
| runs-on: ubuntu-latest | |
| # Only publish the rolling nightly release from pushes to the main branch. | |
| # Stable releases (version tags) are packaged manually/offline. | |
| # | |
| # sign-macos runs on every non-PR event whose ref is main or a v* tag, so it | |
| # is never skipped in a run where this job is eligible -- adding it to | |
| # `needs` propagates no skip. The explicit result check states the intent | |
| # anyway: a nightly is never published off a signing job that failed, was | |
| # cancelled, or did not run. | |
| # | |
| # macos-smoke is in the same position and matters for a different reason: | |
| # it is the only thing in this workflow that answers whether the macOS app | |
| # RUNS. A dmg that is signed, notarized, stapled and unable to load its | |
| # modules passes every other gate here, and did once. | |
| if: >- | |
| github.event_name == 'push' && github.ref == 'refs/heads/main' && | |
| needs.sign-macos.result == 'success' && | |
| needs.macos-smoke.result == 'success' && | |
| needs.provenance.result == 'success' | |
| environment: nightly-release-approval | |
| permissions: | |
| # Deletes and re-creates the nightly tag and its release. | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 | |
| with: | |
| lfs: "false" | |
| submodules: recursive | |
| - name: Re-create nightly tag | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| cd ${{github.workspace}} | |
| gh release delete nightly --repo saturneric/GpgFrontend --cleanup-tag --yes || true | |
| git tag -f nightly $GITHUB_SHA | |
| git push origin nightly --force | |
| # Deliberately two downloads. merge-multiple flattens everything into | |
| # one directory, which is what the deliverables want and what the | |
| # provenance bundles must not have: they are matched to artifacts by | |
| # name, so they need a directory of their own to be told apart from what | |
| # they cover. | |
| - name: Download Artifacts | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| path: artifacts/ | |
| pattern: gpgfrontend-* | |
| merge-multiple: true | |
| - name: Download Build Records | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| name: gpgfrontend-build-records | |
| path: artifacts/ | |
| - name: Download Provenance Bundles | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| name: gpgfrontend-provenance | |
| path: artifacts/provenance/ | |
| - name: Install cosign | |
| uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 | |
| # Verified here as well as produced there. The provenance job could sign | |
| # a bundle this job then fails to attach, or attach one that covers | |
| # different bytes -- and a broken attestation is worse than none, because | |
| # it invites a user to check something that cannot succeed. | |
| # | |
| # The identity is pinned to this workflow in this repository: a valid | |
| # Sigstore signature made by some other workflow is not evidence about | |
| # this release. | |
| - name: Verify Provenance Bundles | |
| run: | | |
| set -euo pipefail | |
| test -d artifacts/provenance || { | |
| echo "no provenance bundles were attached"; exit 1; } | |
| COUNT=0 | |
| while read -r bundle; do | |
| # "<leg>--<filename>.sigstore.json" -> the file it covers | |
| base="$(basename "$bundle" .sigstore.json)" | |
| name="${base#*--}" | |
| artifact="$(find artifacts/ -type f -name "$name" \ | |
| -not -path 'artifacts/provenance/*' | head -1)" | |
| test -n "$artifact" || { | |
| echo "bundle $base covers nothing that was downloaded"; exit 1; } | |
| cosign verify-blob \ | |
| --bundle "$bundle" \ | |
| --certificate-identity-regexp \ | |
| "^https://github.com/${{ github.repository }}/" \ | |
| --certificate-oidc-issuer https://token.actions.githubusercontent.com \ | |
| "$artifact" | |
| echo "verified $name" | |
| COUNT=$((COUNT + 1)) | |
| done < <(find artifacts/provenance -type f -name '*.sigstore.json' | sort) | |
| test "$COUNT" -gt 0 || { echo "no bundles verified"; exit 1; } | |
| echo "$COUNT provenance bundle(s) verified" | |
| # No `.pkg` deletion. download-artifact only sees this run, and the only | |
| # .pkg this project builds comes from mas-sandbox.yml, a different | |
| # workflow whose artifacts this job can never download -- so the find | |
| # matched nothing, every run, since it was written. | |
| - name: Flatten Provenance Bundles | |
| run: | | |
| # Both halves ship: the deliverables and, beside them, the bundles | |
| # and build records that say where they came from. A provenance | |
| # bundle nobody can download is a provenance bundle nobody can check. | |
| if [ -d artifacts/provenance ]; then | |
| mv artifacts/provenance/* artifacts/ 2>/dev/null || true | |
| rmdir artifacts/provenance 2>/dev/null || true | |
| fi | |
| - name: Generate SHA256 checksums | |
| run: | | |
| sha256sum artifacts/* > artifacts/SHA256SUMS.txt | |
| cat artifacts/SHA256SUMS.txt | |
| - name: Generate Nightly Release Title | |
| id: release_title | |
| run: echo "title=Nightly Build $(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT | |
| - name: Update Nightly Release | |
| uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 | |
| with: | |
| tag_name: nightly | |
| name: ${{ steps.release_title.outputs.title }} | |
| draft: false | |
| prerelease: true | |
| body_path: ${{ github.workspace }}/.github/NIGHTLY_RELEASE.md | |
| files: | | |
| artifacts/* |