fix(archive): resolve destination symlinks before extraction #146
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Copyright (C) 2021-2026 Saturneric <eric@bktus.com> | |
| # | |
| # This file is part of GpgFrontend. | |
| # | |
| # GpgFrontend is free software: you can redistribute it and/or modify | |
| # it under the terms of the GNU General Public License as published by | |
| # the Free Software Foundation, either version 3 of the License, or | |
| # (at your option) any later version. | |
| # | |
| # GpgFrontend is distributed in the hope that it will be useful, | |
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| # GNU General Public License for more details. | |
| # | |
| # You should have received a copy of the GNU General Public License | |
| # along with GpgFrontend. If not, see <https://www.gnu.org/licenses/>. | |
| # | |
| # The initial version of the source code is inherited from | |
| # the gpg4usb project, which is under GPL-3.0-or-later. | |
| # | |
| # All the source code of GpgFrontend was modified and released by | |
| # Saturneric <eric@bktus.com> starting on May 12, 2021. | |
| # | |
| # SPDX-License-Identifier: GPL-3.0-or-later | |
| # | |
| # macOS trust boundary | |
| # -------------------- | |
| # The macOS pipeline is split in two on purpose. `build-macos` compiles and | |
| # deploys the bundle and never sees a secret; `sign-macos` holds the Apple | |
| # credentials and runs nothing but Apple's own tools on the finished bundle. | |
| # That keeps the Developer ID private key off any runner that also executes | |
| # build-time code -- submodules, cargo build scripts, brew formulas and | |
| # third-party build actions. | |
| # | |
| # `build-macos` is treated as potentially compromised, so everything it hands | |
| # over is untrusted bytes. `sign-macos` may unpack, inspect and sign that | |
| # payload; it must never source, evaluate, execute, or take configuration or | |
| # signing policy from it. Signing identity, entitlement policy, signing order | |
| # and acceptance checks all live in this file, not in the artifact. | |
| # | |
| # What this does NOT do is establish provenance: a compromised build job can | |
| # still present a malicious payload that the signing job faithfully signs. | |
| # Isolating the key is the goal here; attestation is a separate problem. | |
| # | |
| # `sign-macos` reads these from the `macos-signing` GitHub environment: | |
| # DEVELOP_ID_CERT base64 of the Developer ID .p12 | |
| # DEVELOP_ID_CERT_PWD its export password | |
| # DEVELOPER_ID_CODE_SIGN_IDENTITY the identity string codesign selects | |
| # ASC_API_KEY_P8 contents of the App Store Connect .p8 | |
| # ASC_KEY_ID / ASC_ISSUER_ID its key id and issuer uuid | |
| # Once this flow is verified, these repository secrets can be deleted: | |
| # APPLE_DEVELOPER_ID, APPLE_DEVELOPER_TEAM_ID, APPLE_DEVELOPER_ID_SECRET | |
| # -- replaced by the App Store Connect API key above; | |
| # DEVELOPER_ID_PROVISIONING_PROFILE_DATA, DEVELOPER_ID_PROVISIONING_PROFILE_UUID | |
| # -- the Developer ID entitlements are hardened-runtime exceptions only and | |
| # need no provisioning profile. | |
| # GPGFRONTEND_XCODE_TEAM_ID and the MAS_* secrets stay: mas-sandbox.yml uses them. | |
| name: Build | |
| on: | |
| push: | |
| branches: | |
| - main | |
| tags: | |
| - "v*" | |
| paths-ignore: | |
| - "resource/lfs/locale/**" | |
| - "**.md" | |
| pull_request: | |
| branches: | |
| - main | |
| paths-ignore: | |
| - "resource/lfs/locale/**" | |
| - "**.md" | |
| workflow_dispatch: | |
| inputs: | |
| build_mode: | |
| description: "Build mode" | |
| required: true | |
| default: "nightly" | |
| type: choice | |
| options: | |
| - nightly | |
| - release | |
| # Supersede in-flight work: a new commit on a ref makes the run already going | |
| # for that ref obsolete. Grouping by ref keeps each PR, main, and each v* tag | |
| # in its own lane, so a tag build is never cancelled by unrelated activity. | |
| # Cancelling a run mid-signing is safe: the sign-macos cleanup step is | |
| # `if: always()`, which still fires on cancellation, so the temporary keychain | |
| # and the Apple credentials are removed either way. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| # Least privilege by default; only the release job is granted contents: write. | |
| permissions: | |
| contents: read | |
| env: | |
| BUILD_TYPE: RelWithDebInfo | |
| GNUPG_VERSION: "2.5.21" | |
| jobs: | |
| build: | |
| strategy: | |
| matrix: | |
| # macOS is built and signed by the separate build-macos / sign-macos | |
| # jobs below, so that the Developer ID key never shares a runner with a | |
| # compiler, a package manager or a third-party build action. | |
| os: ["ubuntu-22.04", "ubuntu-24.04-arm", "windows-2022"] | |
| # Portable vs installed is a compile-time decision | |
| # (GPGFRONTEND_BUILD_PORTABLE decides where the profile, and with it the | |
| # user's keys, lives), so each flavour needs its own configure + build. | |
| # They run as separate matrix jobs on purpose: the generated build | |
| # headers land in the source tree, so two flavours cannot share one | |
| # checkout. | |
| flavor: ["installed", "portable"] | |
| runs-on: ${{ matrix.os }} | |
| continue-on-error: true | |
| 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 }}" | |
| - 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 | |
| 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.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 | |
| cmake -B ${{github.workspace}}/build -G Ninja \ | |
| -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} \ | |
| -DGPGFRONTEND_BUILD_APP_IMAGE=ON \ | |
| -DGPGFRONTEND_BUILD_STABLE=${{env.GPGFRONTEND_BUILD_STABLE}} \ | |
| -DGPGFRONTEND_BUILD_PORTABLE=${{env.GPGFRONTEND_BUILD_PORTABLE}} \ | |
| -DGPGFRONTEND_LINK_GPGME_INTO_CORE=ON | |
| cmake --build ${{ github.workspace }}/build \ | |
| --config ${{ env.BUILD_TYPE }} \ | |
| --parallel \ | |
| --verbose | |
| ccache -s | |
| if: runner.os == 'Linux' | |
| - name: Package App Image (Linux) | |
| run: | | |
| QT_PLUGIN_DIR=$(qmake -query QT_INSTALL_PLUGINS) | |
| echo "Found Qt plugin dir: $QT_PLUGIN_DIR" | |
| # enter the sqldrivers directory | |
| cd $QT_PLUGIN_DIR/sqldrivers | |
| # remove all non-sqlite drivers to reduce the size of the final AppImage | |
| find . -type f ! -name '*sqlite*' -delete | |
| ls -l | |
| # return to the root of the repository | |
| cd ${{github.workspace}} | |
| mkdir ${{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 | |
| 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" | |
| chmod u+x linuxdeployqt-continuous.AppImage | |
| export LD_LIBRARY_PATH=/usr/local/lib:/usr/local/lib64:$LD_LIBRARY_PATH | |
| ./linuxdeployqt-continuous.AppImage \ | |
| ${{github.workspace}}/build/artifacts/AppDir/usr/share/applications/*.desktop \ | |
| $EXTRA_ARGS \ | |
| -no-translations \ | |
| -extra-plugins=iconengines,platforms,sqldrivers/libqsqlite.so \ | |
| -appimage \ | |
| -executable=$APP_DIR/usr/lib/libsecret-1.so.0 \ | |
| -executable-dir=${{github.workspace}}/build/artifacts/AppDir/usr/lib/modules | |
| # 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; } | |
| # linuxdeployqt names the image after the .desktop entry, so both | |
| # flavours would come out as Gpg_Frontend-<arch>.AppImage and collide | |
| # once the release job merges every runner's artifacts into one | |
| # directory. Rename to the same scheme the other platforms use. | |
| rm -f linuxdeployqt-continuous.AppImage | |
| for image in Gpg_Frontend*.AppImage; do | |
| mv "$image" \ | |
| "GpgFrontend-${{env.SHORT_SHA}}-${{env.OS_IDENTIFIER}}-${ARCH}${{env.FLAVOR_SUFFIX}}.AppImage" | |
| done | |
| ls -l | |
| if: runner.os == 'Linux' | |
| - name: Build GpgFrontend (Windows) | |
| shell: msys2 {0} | |
| run: | | |
| cd $(cygpath -u "${{github.workspace}}") | |
| cmake -G "Ninja" -S . -B build \ | |
| -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} \ | |
| -DGPGFRONTEND_BUILD_STABLE=${{env.GPGFRONTEND_BUILD_STABLE}} \ | |
| -DGPGFRONTEND_BUILD_PORTABLE=${{env.GPGFRONTEND_BUILD_PORTABLE}} \ | |
| -DGPGFRONTEND_LINK_GPGME_INTO_CORE=ON | |
| cmake --build build \ | |
| --config ${{ env.BUILD_TYPE }} \ | |
| --parallel \ | |
| --verbose | |
| ccache -s | |
| if: runner.os == 'Windows' | |
| - name: Download GnuPG Binary Release (Windows) | |
| shell: msys2 {0} | |
| run: | | |
| export URL="https://ftp.bktus.com/GnuPG/${{env.GNUPG_VERSION}}" | |
| export KEY_URL="https://bktus.com/pgp/saturneric-code-signing.asc" | |
| export KEY_FPR="12F7E8858CF15BEC9975FF3C5CA3DA246843FD03" | |
| export KEY_FILE="saturneric-code-signing.asc" | |
| export FILE="gnupg.zip" | |
| export CHECKSUM_FILE="SHA256SUMS.txt" | |
| export SIGNATURE_FILE="gnupg.zip.sig" | |
| export GNUPGHOME=$(mktemp -d) | |
| cd $(cygpath -u "${{github.workspace}}") | |
| mkdir -p build/downloads | |
| curl -fL --retry 3 -o build/downloads/$FILE $URL/$FILE | |
| curl -fL --retry 3 -o build/downloads/$CHECKSUM_FILE $URL/$CHECKSUM_FILE | |
| curl -fL --retry 3 -o build/downloads/$KEY_FILE $KEY_URL | |
| curl -fL --retry 3 -o build/downloads/$SIGNATURE_FILE $URL/$SIGNATURE_FILE | |
| gpg --import build/downloads/$KEY_FILE | |
| # Trust is pinned to this exact fingerprint, not to whatever the | |
| # downloaded key file happens to contain. | |
| if ! KEY_INFO=$(gpg --batch --with-colons --list-keys "$KEY_FPR"); then | |
| echo "Imported key does not match fingerprint $KEY_FPR!" >&2 | |
| exit 1 | |
| fi | |
| EXPIRES=$(echo "$KEY_INFO" | awk -F: '/^pub:/ {print $7; exit}') | |
| if [ -n "$EXPIRES" ]; then | |
| echo "Signing key expires: $(date -u -d "@$EXPIRES")" | |
| if [ "$EXPIRES" -le "$(date +%s)" ]; then | |
| echo "Signing key has expired!" >&2 | |
| exit 1 | |
| fi | |
| else | |
| echo "Signing key has no expiration date" | |
| fi | |
| # VALIDSIG carries the primary key fingerprint as its last field, so | |
| # this also rejects a valid signature from any other imported key. | |
| if ! gpg --status-fd 1 --verify build/downloads/$SIGNATURE_FILE \ | |
| build/downloads/$FILE | grep "VALIDSIG" | grep -q "$KEY_FPR"; then | |
| echo "GnuPG signature verification failed!" >&2 | |
| exit 1 | |
| fi | |
| CHECKSUM=$(grep "$FILE\$" build/downloads/$CHECKSUM_FILE | awk '{print $1}') | |
| ACTUAL_CHECKSUM=$(sha256sum build/downloads/$FILE | awk '{print $1}') | |
| echo "Expected Checksum: $CHECKSUM" | |
| echo "Actual Checksum: $ACTUAL_CHECKSUM" | |
| if [ "$CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then | |
| echo "Checksum verification failed!" >&2 | |
| exit 1 | |
| fi | |
| mkdir -p build/artifacts/gnupg | |
| # Extraction has to be byte-exact. A text-mode extractor rewrites every | |
| # LF as CRLF, which shifts each PE image away from the offset its | |
| # e_lfanew field points at: the staged binaries then carry no readable | |
| # Authenticode signature and Windows refuses to load them, while the | |
| # archive-level OpenPGP and SHA256 checks above still pass because the | |
| # downloaded zip is intact. bsdtar has no text mode at all. | |
| if command -v bsdtar >/dev/null 2>&1; then | |
| bsdtar -xf build/downloads/$FILE -C build/artifacts/gnupg | |
| else | |
| unzip -o build/downloads/$FILE -d build/artifacts/gnupg/ | |
| fi | |
| # Gate the staged payload rather than trusting the extractor: every | |
| # image must still start with MZ and hold the PE signature exactly | |
| # where e_lfanew points. | |
| BROKEN=0 | |
| COUNT=0 | |
| while IFS= read -r pe; do | |
| COUNT=$((COUNT + 1)) | |
| MZ=$(dd if="$pe" bs=1 count=2 2>/dev/null | od -An -tx1 | tr -d ' \n') | |
| OFF=$(od -An -tu4 -j 60 -N 4 "$pe" | tr -d ' ') | |
| SIG=$(dd if="$pe" bs=1 skip="$OFF" count=4 2>/dev/null | od -An -tx1 | tr -d ' \n') | |
| if [ "$MZ" != "4d5a" ] || [ "$SIG" != "50450000" ]; then | |
| echo "corrupt PE image: $pe (mz=$MZ e_lfanew=$OFF sig=$SIG)" >&2 | |
| BROKEN=$((BROKEN + 1)) | |
| fi | |
| done < <(find build/artifacts/gnupg -type f \( -name '*.exe' -o -name '*.dll' \)) | |
| if [ "$BROKEN" -ne 0 ]; then | |
| echo "$BROKEN of $COUNT staged GnuPG images are not loadable PE files!" >&2 | |
| type -a bsdtar unzip >&2 || true | |
| env | grep -iE '^(UNZIP|UNZIPOPT|ZIPOPT|MSYS|CYGWIN)=' >&2 || true | |
| exit 1 | |
| fi | |
| echo "verified $COUNT staged GnuPG PE images" | |
| ls -l build/artifacts/gnupg/ | |
| if: runner.os == 'Windows' | |
| # Payload staging is flavour-independent: the portable ZIP and the MSI are | |
| # both built from this same tree, only from a differently configured build. | |
| - name: Stage Payload (Windows) | |
| shell: msys2 {0} | |
| run: | | |
| cd $(cygpath -u "${{github.workspace}}") | |
| cp PrivacyPolicy.md build/artifacts/ | |
| cp README.md build/artifacts/ | |
| cp SECURITY.md build/artifacts/ | |
| cp TRANSLATORS build/artifacts/ | |
| cp COPYING build/artifacts/ | |
| cp gpgfrontend.ico build/artifacts/bin/ | |
| rm -rf build/artifacts/bin/*.a | |
| rm -rf build/artifacts/bin/modules/*.a | |
| mv build/artifacts/bin/modules build/artifacts/modules | |
| cd build | |
| # Deploy every gf_* library rather than a hand-kept list, so a newly | |
| # registered library (gf_res was the last one) cannot be forgotten. | |
| for lib in ./artifacts/bin/libgf_*.dll; do | |
| windeployqt6 --no-translations --force "$lib" | |
| done | |
| 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 ./artifacts/modules/*.dll; do | |
| windeployqt6 --no-translations --force --dir ./artifacts/bin "$module" | |
| done | |
| mkdir -p upload-artifact | |
| 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: | | |
| # Version always tracks the CMake project() version, no wxs edit needed. | |
| $match = Select-String -Path CMakeLists.txt ` | |
| -Pattern '^\s*VERSION\s+([0-9]+\.[0-9]+\.[0-9]+)' | Select-Object -First 1 | |
| if (-not $match) { throw "Could not extract project version from CMakeLists.txt" } | |
| $version = $match.Matches[0].Groups[1].Value | |
| 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: 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* | |
| 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: | |
| - name: Set git to use LF line endings | |
| run: | | |
| git config --global core.autocrlf false | |
| git config --global core.eol lf | |
| - 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 | |
| - 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 | |
| - 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" | |
| # 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 \ | |
| -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/ | |
| - name: Deploy Qt | |
| run: | | |
| # No -codesign=: macdeployqt only ever signed what it copied itself. | |
| # sign-macos signs every code object explicitly, inside out. | |
| macdeployqt ${{github.workspace}}/build/package/GpgFrontend.app \ | |
| -verbose=2 \ | |
| -always-overwrite | |
| - name: Inventory Bundle Code Objects | |
| run: | | |
| set -euo pipefail | |
| APP="${{github.workspace}}/build/package/GpgFrontend.app" | |
| # This listing is the ground truth that sign-macos's explicit signer | |
| # has to match. Read it whenever the bundle layout could have moved: | |
| # every object below must map to a deliberate rule over there. | |
| echo "--- nested bundles ---" | |
| find "$APP/Contents" -type d \ | |
| \( -name '*.framework' -o -name '*.app' -o -name '*.appex' \ | |
| -o -name '*.xpc' -o -name '*.bundle' -o -name '*.systemextension' \ | |
| -o -name '*.pluginkit' \) | sed "s#^$APP/##" | |
| echo "--- Mach-O files and their signature state as built ---" | |
| # CODE_SIGNING_ALLOWED=NO leaves the Xcode products bare, while | |
| # macdeployqt ad-hoc signs some of what it copies even without | |
| # -codesign. Record which is which. | |
| # | |
| # `codesign -dv` exits non-zero on an unsigned object -- the normal | |
| # state here -- so its status is classified rather than allowed to | |
| # abort the loop. A real signature reports "Signature size=", only an | |
| # ad-hoc one reports "Signature=adhoc". | |
| find "$APP/Contents" -type f | while IFS= read -r f; do | |
| if file -b "$f" | grep -q 'Mach-O'; then | |
| out=$(codesign -dv "$f" 2>&1 || true) | |
| case "$out" in | |
| *"not signed at all"*) state=unsigned ;; | |
| *"Signature=adhoc"*) state=adhoc ;; | |
| *"Signature size="*) state=signed ;; | |
| *) state=unknown ;; | |
| esac | |
| printf '%-9s %s\n' "$state" "${f#"$APP"/}" | |
| fi | |
| done | |
| - 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/* | |
| retention-days: 5 | |
| # Holds the Apple credentials and nothing else. No checkout, no package | |
| # manager, no compiler, no Qt, no cargo, no cmake -- only Apple's own tools | |
| # plus the two artifact actions. The downloaded bundle is untrusted payload: | |
| # it is unpacked, inspected and signed, never sourced, evaluated, executed, or | |
| # consulted for configuration or signing policy. | |
| 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> | |
| <key>com.apple.security.cs.disable-library-validation</key> | |
| <true/> | |
| <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}" | |
| sign_inner() { # nested code: hardened runtime, never entitlements | |
| codesign --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. | |
| codesign --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 | |
| - 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" | |
| hdiutil create -format UDRW -volname GpgFrontend -srcfolder "$STAGE" -ov "$RW" | |
| hdiutil attach "$RW" -nobrowse -readwrite -noverify -mountpoint "$MNT" | |
| # 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 | |
| # Detach can lose a race with a background scan; retry once forcibly. | |
| hdiutil detach "$MNT" || { sleep 5; hdiutil detach "$MNT" -force; } | |
| hdiutil convert "$RW" -format UDZO -o "$DMG" -ov | |
| rm -f "$RW" | |
| codesign --force --timestamp --sign "$IDENTITY" "$DMG" | |
| "$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" | |
| release: | |
| needs: [build, sign-macos] | |
| 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. | |
| if: >- | |
| github.event_name == 'push' && github.ref == 'refs/heads/main' && | |
| needs.sign-macos.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 | |
| - name: Download Artifacts | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| path: artifacts/ | |
| pattern: gpgfrontend-* | |
| merge-multiple: true | |
| - name: Ignore Non-Artifact Files | |
| run: | | |
| # Remove MAS specific .pkg files if they exist, since they are not | |
| # needed for the release and we only want to keep the main artifacts | |
| # (dmg, AppImage, zip). | |
| find artifacts/ -type f -name '*.pkg' -delete | |
| - name: Generate SHA256 checksums | |
| run: | | |
| sha256sum artifacts/* > artifacts/SHA256SUMS.txt | |
| cat artifacts/SHA256SUMS.txt | |
| - name: List files | |
| run: ls -rl artifacts/ | |
| - 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/* |