Skip to content

chore(modules): update submodule revision #181

chore(modules): update submodule revision

chore(modules): update submodule revision #181

Workflow file for this run

# 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
# Native binding is REQUIRED here and OFF by default, which is the
# one place the two legitimately differ. An AppImage has no
# map-time code-integrity enforcement of its own -- nothing like
# Apple's signature checked by dyld -- so the exact-ELF binding is
# the only thing that would notice a stale, corrupt or simply wrong
# native inside the image. --require-binding below proves this
# setting was not lost, because an unbound tree verifies perfectly
# well without it.
#
# 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 \
-DGPGFRONTEND_INTEGRATED_MODULE_NATIVE_BINDING=REQUIRED \
-DGPGFRONTEND_INTEGRATED_MODULE_BINDING_ALGORITHM=FILE_SHA256
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.
# --require-binding, because this leg MUST ship bound artifacts and
# the build default is the weaker one. Without it a configure line
# that lost -DGPGFRONTEND_INTEGRATED_MODULE_NATIVE_BINDING=REQUIRED
# would verify green and release an unbound AppImage.
${{github.workspace}}/scripts/audit_module_natives.sh \
--namespace-root "$MODULE_ROOT" \
--packager "$PACKAGER" \
--qt-relative ../../../.. \
--expect-count "$EXPECTED" \
--require-binding \
--assert-no-native-outside ${{github.workspace}}/build/artifacts/AppDir
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 \
-DGPGFRONTEND_INTEGRATED_MODULE_NATIVE_BINDING=REQUIRED \
-DGPGFRONTEND_INTEGRATED_MODULE_BINDING_ALGORITHM=PE_AUTHENTICODE_SHA256
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.
#
# One call: the audit first verifies the descriptors strictly, then asks
# the packager which native each signed descriptor binds and audits it.
- 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)"
# --require-binding: see the AppImage leg. Windows must ship bound
# artifacts, and an unbound tree verifies fine without this flag.
./scripts/audit_module_natives.sh \
--namespace-root "$MODULE_ROOT" \
--packager "$PACKAGER" \
--expect-count "$EXPECTED" \
--require-binding \
--assert-no-native-outside build/artifacts
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 --require-binding
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 --require-binding
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" \
"$TOOL_DIR/gf_module_externalize.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 'gf_module_externalize*' \
-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
python3 ./scripts/write_build_records.py \
--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/*