Release — Build All Installers #16
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
| name: Release — Build All Installers | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Triggered on: | |
| # • git tag push (v*) → builds installers, creates GH Release, updates README | |
| # • workflow_dispatch → manual run (skips release + README update) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| on: | |
| push: | |
| tags: | |
| - 'v*' | |
| workflow_dispatch: | |
| env: | |
| BUILD_TYPE: Release | |
| QT_VERSION: "6.8.3" | |
| QT_MODULES: "qtcharts qtwebsockets qtmultimedia" | |
| QT_IFW_VERSION: "4.7" | |
| QT_IFW_TOOL: "tools_ifw" | |
| APP_NAME: FinceptTerminal | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| # ═════════════════════════════════════════════════════════════════════════════ | |
| # JOB 1 — Windows x64 Installer (QtIFW .exe) | |
| # ═════════════════════════════════════════════════════════════════════════════ | |
| jobs: | |
| build-windows: | |
| name: Windows x64 Installer | |
| runs-on: windows-2022 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: recursive | |
| fetch-depth: 1 | |
| # ── OpenSSL dev libs ────────────────────────────────────────────────── | |
| - name: Install OpenSSL | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| VCPKG_ROOT="${VCPKG_INSTALLATION_ROOT:-C:/vcpkg}" | |
| "$VCPKG_ROOT/vcpkg" install openssl:x64-windows --no-print-usage | |
| OPENSSL_DIR="$VCPKG_ROOT/installed/x64-windows" | |
| echo "OPENSSL_ROOT_DIR=$OPENSSL_DIR" >> "$GITHUB_ENV" | |
| ls "$OPENSSL_DIR/lib/libcrypto.lib" && echo "libcrypto.lib confirmed" | |
| # ── Qt ──────────────────────────────────────────────────────────────── | |
| - name: Install Qt ${{ env.QT_VERSION }} | |
| uses: jurplel/install-qt-action@v4 | |
| with: | |
| version: ${{ env.QT_VERSION }} | |
| arch: win64_msvc2022_64 | |
| modules: ${{ env.QT_MODULES }} | |
| cache: true | |
| cache-key-prefix: qt-windows-x64-release | |
| # ── Qt Installer Framework ──────────────────────────────────────────── | |
| - name: Install Qt Installer Framework | |
| shell: bash | |
| run: | | |
| pip install aqtinstall | |
| aqt install-tool windows desktop ${{ env.QT_IFW_TOOL }} \ | |
| --outputdir "${{ runner.temp }}/qtifw" | |
| IFW_DIR="${{ runner.temp }}/qtifw/Tools/QtInstallerFramework/${{ env.QT_IFW_VERSION }}" | |
| echo "QT_IFW_DIR=${IFW_DIR}" >> "$GITHUB_ENV" | |
| echo "PATH=${IFW_DIR}/bin:${PATH}" >> "$GITHUB_ENV" | |
| # ── Configure + Build ───────────────────────────────────────────────── | |
| - name: Configure CMake | |
| shell: bash | |
| working-directory: fincept-qt | |
| run: | | |
| cmake -B build \ | |
| -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} \ | |
| -DCMAKE_PREFIX_PATH="${QT_ROOT_DIR}" \ | |
| -DOPENSSL_ROOT_DIR="${OPENSSL_ROOT_DIR}" \ | |
| -DDEPLOY_QT=ON \ | |
| -DFINCEPT_BUILD_INSTALLER=ON | |
| - name: Build | |
| shell: bash | |
| working-directory: fincept-qt | |
| run: cmake --build build --config ${{ env.BUILD_TYPE }} --parallel 4 | |
| # ── Verify ─────────────────────────────────────────────────────────── | |
| - name: Verify binary | |
| shell: bash | |
| working-directory: fincept-qt | |
| run: | | |
| BINARY="build/Release/${{ env.APP_NAME }}.exe" | |
| [ -f "$BINARY" ] || { echo "::error::Binary not found"; exit 1; } | |
| echo "Binary: $BINARY ($(du -sh "$BINARY" | cut -f1))" | |
| # ── Deploy Qt DLLs ──────────────────────────────────────────────────── | |
| # Mirrors the matrix build in build-cpp.yml — both must stage the SAME | |
| # set of DLLs, plugins, and resources or the installer ships a broken | |
| # app. Any addition here should also go into build-cpp.yml (and vice | |
| # versa). Philosophy: ship everything windeployqt might miss, even if | |
| # it's redundant — a 10MB larger installer is better than a crashing app. | |
| - name: Deploy Qt DLLs (windeployqt) | |
| shell: bash | |
| working-directory: fincept-qt | |
| run: | | |
| set -euo pipefail | |
| BUILD_DIR="build/Release" | |
| QT_BIN="${QT_ROOT_DIR}/bin" | |
| QT_PLUGINS="${QT_ROOT_DIR}/plugins" | |
| WDQT="${QT_BIN}/windeployqt.exe" | |
| [ -f "$WDQT" ] && "$WDQT" --release --no-translations --compiler-runtime \ | |
| --include-soft-plugins --force "${BUILD_DIR}/${{ env.APP_NAME }}.exe" | |
| # qgeoview.dll is emitted by a FetchContent target and copied next to | |
| # the exe by a POST_BUILD custom_command — confirm it's present so we | |
| # catch the case where the custom_command silently failed. | |
| if [ -f "${BUILD_DIR}/qgeoview.dll" ]; then | |
| echo "qgeoview.dll present" | |
| else | |
| echo "::warning::qgeoview.dll missing from ${BUILD_DIR} — map widget will fail to load" | |
| fi | |
| # Qt Core DLLs — belt-and-suspenders for anything windeployqt misses. | |
| for qt_dll in \ | |
| Qt6Core.dll Qt6Gui.dll Qt6Widgets.dll Qt6Charts.dll Qt6PrintSupport.dll \ | |
| Qt6Network.dll Qt6Sql.dll Qt6Concurrent.dll Qt6WebSockets.dll \ | |
| Qt6Multimedia.dll Qt6MultimediaWidgets.dll Qt6OpenGL.dll \ | |
| Qt6OpenGLWidgets.dll Qt6Svg.dll Qt6Xml.dll Qt6TextToSpeech.dll; do | |
| [ -f "${QT_BIN}/${qt_dll}" ] && [ ! -f "${BUILD_DIR}/${qt_dll}" ] && \ | |
| cp "${QT_BIN}/${qt_dll}" "${BUILD_DIR}/" || true | |
| done | |
| # OpenSSL — Qt6::Network needs these for HTTPS. Qt ships them in its | |
| # own bin/. Include the 1.1 names as a fallback for older Qt kits. | |
| for ssl_dll in \ | |
| libssl-3-x64.dll libcrypto-3-x64.dll \ | |
| libssl-3.dll libcrypto-3.dll \ | |
| libssl-1_1-x64.dll libcrypto-1_1-x64.dll; do | |
| [ -f "${QT_BIN}/${ssl_dll}" ] && [ ! -f "${BUILD_DIR}/${ssl_dll}" ] && \ | |
| cp "${QT_BIN}/${ssl_dll}" "${BUILD_DIR}/" || true | |
| [ -f "${OPENSSL_ROOT_DIR}/bin/${ssl_dll}" ] && [ ! -f "${BUILD_DIR}/${ssl_dll}" ] && \ | |
| cp "${OPENSSL_ROOT_DIR}/bin/${ssl_dll}" "${BUILD_DIR}/" || true | |
| done | |
| # MSVC runtime — --compiler-runtime covers most but not all VS2022 17.x | |
| # DLLs (msvcp170, vcruntime140_2, vccorlib140 are sometimes missed). | |
| for vcrt_dll in \ | |
| vcruntime140.dll vcruntime140_1.dll vcruntime140_2.dll \ | |
| msvcp140.dll msvcp140_1.dll msvcp140_2.dll \ | |
| msvcp170.dll \ | |
| concrt140.dll vccorlib140.dll; do | |
| SYS32="/c/Windows/System32/${vcrt_dll}" | |
| [ -f "$SYS32" ] && [ ! -f "${BUILD_DIR}/${vcrt_dll}" ] && \ | |
| cp "$SYS32" "${BUILD_DIR}/" || true | |
| done | |
| # Qt plugin directories — whole-dir copy so we pick up every provided | |
| # plugin without maintaining a per-file allowlist. | |
| for plugin_dir in platforms sqldrivers imageformats iconengines styles tls \ | |
| multimedia texttospeech networkinformation platforminputcontexts printsupport generic; do | |
| [ -d "${QT_PLUGINS}/${plugin_dir}" ] && \ | |
| { mkdir -p "${BUILD_DIR}/${plugin_dir}"; \ | |
| cp -n "${QT_PLUGINS}/${plugin_dir}/"*.dll "${BUILD_DIR}/${plugin_dir}/" 2>/dev/null || true; } | |
| done | |
| # Direct3D compiler + OpenGL SW fallback — needed on VMs / headless | |
| # machines without a proper GPU driver. dxcompiler/dxil: Qt 6.6+ D3D12 | |
| # backend (DXC shader compiler) — on Win11 boxes without WinSDK. | |
| for d3d_dll in \ | |
| d3dcompiler_47.dll opengl32sw.dll libEGL.dll libGLESv2.dll \ | |
| dxcompiler.dll dxil.dll; do | |
| [ -f "${QT_BIN}/${d3d_dll}" ] && [ ! -f "${BUILD_DIR}/${d3d_dll}" ] && \ | |
| cp "${QT_BIN}/${d3d_dll}" "${BUILD_DIR}/" || true | |
| done | |
| # ICU — Qt 6 on Windows ships its own ICU for Unicode support. | |
| for icu_dll in "${QT_BIN}"/icu*.dll; do | |
| base=$(basename "$icu_dll") | |
| [ -f "$icu_dll" ] && [ ! -f "${BUILD_DIR}/${base}" ] && \ | |
| cp "$icu_dll" "${BUILD_DIR}/" || true | |
| done | |
| cp -r resources "${BUILD_DIR}/resources" 2>/dev/null || true | |
| bash "${GITHUB_WORKSPACE}/.github/scripts/sync_scripts.sh" scripts "${BUILD_DIR}/scripts" | |
| # qt.conf — pins plugin and data paths relative to the executable. | |
| # Without this, Qt falls back to hardcoded build-time paths which | |
| # don't exist on a clean machine and cause plugin load failures. | |
| printf '[Paths]\nPrefix = .\nPlugins = .\nLibraries = .\n' > "${BUILD_DIR}/qt.conf" | |
| # Fail early if anything essential didn't make it into the stage dir. | |
| echo "" | |
| echo "=== Staged Windows payload ===" | |
| MISSING=0 | |
| for essential in \ | |
| "${{ env.APP_NAME }}.exe" \ | |
| Qt6Core.dll Qt6Gui.dll Qt6Widgets.dll Qt6Network.dll \ | |
| platforms/qwindows.dll sqldrivers/qsqlite.dll \ | |
| tls/qschannelbackend.dll; do | |
| if [ ! -f "${BUILD_DIR}/${essential}" ]; then | |
| echo "::error::Missing essential file: ${essential}" | |
| MISSING=1 | |
| fi | |
| done | |
| [ "$MISSING" -eq 1 ] && exit 1 | |
| echo "=== $(find "${BUILD_DIR}" -type f | wc -l) files staged ===" | |
| # ── Generate Installer ──────────────────────────────────────────────── | |
| - name: Generate Installer (CPack IFW) | |
| shell: bash | |
| working-directory: fincept-qt/build | |
| run: | | |
| set -euo pipefail | |
| export PATH="${QT_IFW_DIR}/bin:${PATH}" | |
| command -v binarycreator || { echo "::error::binarycreator not found"; exit 1; } | |
| cpack -G IFW -C ${{ env.BUILD_TYPE }} --verbose | |
| ls -lh ${{ env.APP_NAME }}-*-setup* 2>/dev/null || ls -lh *.exe 2>/dev/null || true | |
| # ── Upload ──────────────────────────────────────────────────────────── | |
| # Narrow the glob to the installer output — the broader `*.exe` pattern | |
| # would also catch the main FinceptTerminal.exe binary, which we don't | |
| # want to publish as a release asset (users should run the installer). | |
| - name: Upload artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: FinceptTerminal-Windows-x64-installer | |
| path: fincept-qt/build/FinceptTerminal-*-windows-x64-setup.exe | |
| retention-days: 30 | |
| # ═════════════════════════════════════════════════════════════════════════════ | |
| # JOB 2 — Linux x64 Installer (self-contained AppImage, served as .run) | |
| # ═════════════════════════════════════════════════════════════════════════════ | |
| build-linux: | |
| name: Linux x64 Installer | |
| runs-on: ubuntu-22.04 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: recursive | |
| fetch-depth: 1 | |
| - name: Set up ccache | |
| uses: hendrikmuhs/ccache-action@v1 | |
| with: | |
| key: linux-x64-release-${{ env.QT_VERSION }} | |
| max-size: 500M | |
| update-package-index: true | |
| # ── System deps ─────────────────────────────────────────────────────── | |
| # ubuntu-22.04 ships g++ 11.4 by default, but CMakeLists.txt enforces | |
| # GCC 12.3+. Install gcc-12/g++-12 and pin CC/CXX for the rest of the job. | |
| # We intentionally do NOT install apt Qt packages — ubuntu-22.04 ships | |
| # Qt 6.2 and CMakeLists pins Qt 6.8.3 EXACT. Qt is installed via | |
| # jurplel/install-qt-action below, matching the Windows and macOS jobs. | |
| # We still need the XCB / GL system libs so Qt's xcb platform plugin | |
| # can link at build time and load at runtime. | |
| - name: Install system dependencies | |
| run: | | |
| sudo add-apt-repository universe -y | |
| sudo apt-get update -qq | |
| sudo apt-get install -y \ | |
| cmake ninja-build gcc-12 g++-12 \ | |
| libssl-dev \ | |
| libgl1-mesa-dev libglu1-mesa-dev \ | |
| libxkbcommon-dev libxkbcommon-x11-0 \ | |
| libxcb-cursor0 libxcb-cursor-dev \ | |
| libxcb-icccm4 libxcb-icccm4-dev \ | |
| libxcb-image0 libxcb-image0-dev \ | |
| libxcb-keysyms1 libxcb-keysyms1-dev \ | |
| libxcb-randr0 \ | |
| libxcb-render-util0 libxcb-render-util0-dev \ | |
| libxcb-shape0-dev \ | |
| libxcb-sync-dev \ | |
| libxcb-xfixes0-dev \ | |
| libxcb-xinerama0 libxcb-xinerama0-dev \ | |
| libxcb-xkb1 libxkbcommon-x11-dev \ | |
| libdbus-1-dev libfontconfig1-dev libfreetype6-dev \ | |
| python3 python3-pip pkg-config zip \ | |
| wget file libfuse2 patchelf | |
| echo "CC=gcc-12" >> "$GITHUB_ENV" | |
| echo "CXX=g++-12" >> "$GITHUB_ENV" | |
| # ── Qt 6.8.3 (matches CMakeLists' Qt6 EXACT pin) ────────────────────── | |
| - name: Install Qt ${{ env.QT_VERSION }} | |
| uses: jurplel/install-qt-action@v4 | |
| with: | |
| version: ${{ env.QT_VERSION }} | |
| arch: linux_gcc_64 | |
| modules: ${{ env.QT_MODULES }} | |
| cache: true | |
| cache-key-prefix: qt-linux-x64-release | |
| # ── Configure + Build ───────────────────────────────────────────────── | |
| # No FINCEPT_BUILD_INSTALLER / no QtIFW on Linux — we ship an AppImage | |
| # instead (see "Build AppImage" step below). | |
| - name: Configure CMake | |
| working-directory: fincept-qt | |
| run: | | |
| cmake -B build -G Ninja \ | |
| -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} \ | |
| -DCMAKE_PREFIX_PATH="${QT_ROOT_DIR}" \ | |
| -DOPENSSL_ROOT_DIR=/usr \ | |
| -DCMAKE_C_COMPILER_LAUNCHER=ccache \ | |
| -DCMAKE_CXX_COMPILER_LAUNCHER=ccache | |
| - name: Build | |
| working-directory: fincept-qt | |
| run: cmake --build build --config ${{ env.BUILD_TYPE }} --parallel $(nproc) | |
| # ── Verify ─────────────────────────────────────────────────────────── | |
| - name: Verify binary | |
| working-directory: fincept-qt | |
| run: | | |
| BINARY="build/${{ env.APP_NAME }}" | |
| [ -f "$BINARY" ] || { echo "::error::Binary not found"; exit 1; } | |
| echo "Binary: $BINARY ($(du -sh "$BINARY" | cut -f1))" | |
| file "$BINARY" | |
| # ── Build self-contained AppImage ───────────────────────────────────── | |
| # The previous release workflow ran CPack IFW on Linux but didn't bundle | |
| # Qt at all — the installer produced a "binary + desktop file" payload | |
| # that crashed on every machine missing the matching apt Qt. We now ship | |
| # an AppImage instead: linuxdeploy walks the binary's needed-libraries | |
| # list, pulls in every transitive .so (Qt + XCB + ICU + OpenSSL + GL), | |
| # patchelfs the RPATH, and bakes it all into one executable file the | |
| # auto-updater can chmod+launch like a .run. | |
| # | |
| # Filename keeps the `-linux-x64-setup.run` suffix so it matches the | |
| # regex in update-manifest (AppImage is just a self-mounting executable | |
| # — the updater's "chmod +x and launch" path works identically). | |
| - name: Build AppImage | |
| working-directory: fincept-qt | |
| run: | | |
| set -euo pipefail | |
| APPDIR="build/AppDir" | |
| mkdir -p "${APPDIR}/usr/bin" | |
| mkdir -p "${APPDIR}/usr/share/applications" | |
| mkdir -p "${APPDIR}/usr/share/icons/hicolor/256x256/apps" | |
| # Stage binary + desktop entry + icon | |
| cp "build/${{ env.APP_NAME }}" "${APPDIR}/usr/bin/" | |
| chmod +x "${APPDIR}/usr/bin/${{ env.APP_NAME }}" | |
| cat > "${APPDIR}/usr/share/applications/fincept-terminal.desktop" <<'EOF' | |
| [Desktop Entry] | |
| Name=Fincept Terminal | |
| Exec=FinceptTerminal | |
| Icon=fincept-terminal | |
| Type=Application | |
| Categories=Finance; | |
| EOF | |
| # Icon — linuxdeploy requires a valid PNG (it runs libpng/CImg over | |
| # the file to generate scaled variants). Convert the repo's .ico to | |
| # a 256x256 PNG via ImageMagick (pre-installed on ubuntu-22.04). Fall | |
| # back to a generated solid-color PNG only if no source icon exists — | |
| # never hand-write PNG bytes inline, ImageMagick's CRC check rejects | |
| # anything with a malformed IDAT chunk. | |
| ICON_DEST="${APPDIR}/usr/share/icons/hicolor/256x256/apps/fincept-terminal.png" | |
| if [ -f "resources/icons/app_icon_256.png" ]; then | |
| cp "resources/icons/app_icon_256.png" "${ICON_DEST}" | |
| elif [ -f "resources/fincept.png" ]; then | |
| cp "resources/fincept.png" "${ICON_DEST}" | |
| elif [ -f "resources/fincept.ico" ]; then | |
| # .ico may hold multiple frames — "ico:file[0]" selects the first, | |
| # which is conventionally the largest/primary. Explicit "ico:" prefix | |
| # forces the ICO decoder in case policy.xml has format detection quirks. | |
| convert "ico:resources/fincept.ico[0]" -resize 256x256 -background none -flatten "png:${ICON_DEST}" | |
| else | |
| # Solid-color 256x256 PNG as last resort. ImageMagick writes it | |
| # with a valid IDAT + CRC, unlike an inline printf of raw bytes. | |
| convert -size 256x256 xc:'#1a1a1a' "png:${ICON_DEST}" | |
| fi | |
| # Sanity check — a broken PNG here will fail the AppImage build | |
| # 30 seconds later inside linuxdeploy, so fail fast with a clear error. | |
| # Check: file exists, is non-empty, `file` reports PNG, and libpng's | |
| # own `pngcheck`-equivalent (imagemagick `identify`) can parse it. | |
| if [ ! -s "${ICON_DEST}" ]; then | |
| echo "::error::Icon at ${ICON_DEST} is missing or empty" | |
| ls -la "${ICON_DEST}" 2>&1 || true | |
| exit 1 | |
| fi | |
| if ! file "${ICON_DEST}" | grep -q 'PNG image data'; then | |
| echo "::error::Icon at ${ICON_DEST} is not a valid PNG" | |
| file "${ICON_DEST}" | |
| exit 1 | |
| fi | |
| if ! identify "${ICON_DEST}" >/dev/null 2>&1; then | |
| echo "::error::Icon at ${ICON_DEST} failed ImageMagick identify — libpng will reject it" | |
| identify "${ICON_DEST}" 2>&1 || true | |
| exit 1 | |
| fi | |
| echo "Icon OK: $(identify -format '%wx%h %m' "${ICON_DEST}")" | |
| # App resources + Python analytics scripts live next to the binary | |
| # (matches AppPaths resolution on Linux). | |
| cp -r resources "${APPDIR}/usr/bin/resources" 2>/dev/null || true | |
| bash "${GITHUB_WORKSPACE}/.github/scripts/sync_scripts.sh" scripts "${APPDIR}/usr/bin/scripts" | |
| # Requirements files — PythonSetupManager reads these at first run. | |
| mkdir -p "${APPDIR}/usr/bin/resources" | |
| cp resources/requirements-numpy1.txt "${APPDIR}/usr/bin/resources/" 2>/dev/null || true | |
| cp resources/requirements-numpy2.txt "${APPDIR}/usr/bin/resources/" 2>/dev/null || true | |
| # Download linuxdeploy + Qt plugin | |
| wget -q "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" | |
| wget -q "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage" | |
| chmod +x linuxdeploy-x86_64.AppImage linuxdeploy-plugin-qt-x86_64.AppImage | |
| # linuxdeploy-plugin-qt uses qmake to locate Qt. Point it at the | |
| # jurplel-installed Qt 6.8.3 rather than the (absent) apt qmake6. | |
| # Without this the plugin either no-ops or picks up system Qt. | |
| export PATH="${QT_ROOT_DIR}/bin:${PATH}" | |
| export LD_LIBRARY_PATH="${QT_ROOT_DIR}/lib:${LD_LIBRARY_PATH:-}" | |
| # ── Strip unused Qt SQL driver plugins ─────────────────────────── | |
| # We only use SQLite. linuxdeploy-plugin-qt walks every plugin in | |
| # Qt's sqldrivers/ folder and tries to resolve its dependencies — | |
| # the Mimer plugin pulls in libmimerapi.so, which isn't installed | |
| # on the runner and causes the deploy step to fail. | |
| if [ -d "${QT_ROOT_DIR}/plugins/sqldrivers" ]; then | |
| rm -f "${QT_ROOT_DIR}/plugins/sqldrivers/libqsqlmimer.so" \ | |
| "${QT_ROOT_DIR}/plugins/sqldrivers/libqsqlodbc.so" \ | |
| "${QT_ROOT_DIR}/plugins/sqldrivers/libqsqlpsql.so" \ | |
| "${QT_ROOT_DIR}/plugins/sqldrivers/libqsqlmysql.so" | |
| fi | |
| # Filename must match the regex in update-manifest: | |
| # FinceptTerminal-<version>-linux-x64-setup.run | |
| # where <version> is CMAKE_PROJECT_VERSION (e.g. 4.0.2), NOT the | |
| # tag (v4.0.2). Parse project() VERSION from CMakeLists.txt so we | |
| # stay in sync even when triggered via workflow_dispatch (no tag). | |
| VERSION=$(grep -Po 'project\(FinceptTerminal VERSION \K[0-9]+\.[0-9]+\.[0-9]+' CMakeLists.txt) | |
| if [ -z "${VERSION}" ]; then | |
| echo "::error::Could not parse project VERSION from CMakeLists.txt" | |
| exit 1 | |
| fi | |
| echo "Package version: ${VERSION}" | |
| # Build the AppImage. The qt plugin traces Qt plugin dependencies | |
| # (platforms, sqldrivers, imageformats, tls, iconengines, svg, etc.) | |
| # in addition to the core Qt libs. UPDATE_INFORMATION embeds the | |
| # zsync update URL so AppImageUpdate can fetch deltas later. | |
| export QMAKE="${QT_ROOT_DIR}/bin/qmake" | |
| export OUTPUT="FinceptTerminal-${VERSION}-linux-x64-setup.run" | |
| export UPDATE_INFORMATION="gh-releases-zsync|Fincept-Corporation|FinceptTerminal|latest|FinceptTerminal-*-linux-x64-setup.run.zsync" | |
| ./linuxdeploy-x86_64.AppImage \ | |
| --appdir "${APPDIR}" \ | |
| --plugin qt \ | |
| --output appimage \ | |
| --desktop-file "${APPDIR}/usr/share/applications/fincept-terminal.desktop" \ | |
| --icon-file "${ICON_DEST}" \ | |
| 2>&1 | |
| # linuxdeploy names the output after the desktop entry — find it. | |
| if [ ! -f "${OUTPUT}" ]; then | |
| PRODUCED=$(find . -maxdepth 1 -name "*.AppImage" | head -1) | |
| if [ -z "${PRODUCED}" ]; then | |
| echo "::error::AppImage build failed — no .AppImage produced" | |
| ls -la | |
| exit 1 | |
| fi | |
| mv "${PRODUCED}" "${OUTPUT}" | |
| fi | |
| chmod +x "${OUTPUT}" | |
| # Sanity check — AppImage must be ≥ 50MB (bare binary is ~20MB, +Qt | |
| # libs brings it to 80-150MB). A tiny AppImage means Qt wasn't bundled. | |
| SIZE_KB=$(du -k "${OUTPUT}" | cut -f1) | |
| if [ "${SIZE_KB}" -lt 51200 ]; then | |
| echo "::error::AppImage is only ${SIZE_KB}KB — Qt libs likely not bundled" | |
| exit 1 | |
| fi | |
| # Stage in build/ where the upload step expects it. | |
| mkdir -p build | |
| mv "${OUTPUT}" "build/${OUTPUT}" | |
| echo "AppImage: build/${OUTPUT} ($(du -sh "build/${OUTPUT}" | cut -f1))" | |
| # Generate zsync file for delta updates (optional — manifest job | |
| # works fine without it). | |
| if command -v zsyncmake >/dev/null 2>&1; then | |
| zsyncmake "build/${OUTPUT}" -o "build/${OUTPUT}.zsync" || true | |
| else | |
| sudo apt-get install -y zsync -qq 2>/dev/null || true | |
| command -v zsyncmake >/dev/null 2>&1 && \ | |
| zsyncmake "build/${OUTPUT}" -o "build/${OUTPUT}.zsync" || true | |
| fi | |
| # ── Upload ──────────────────────────────────────────────────────────── | |
| # Version-stamped filename so update-manifest regex picks it up. | |
| - name: Upload artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: FinceptTerminal-Linux-x64-installer | |
| path: | | |
| fincept-qt/build/FinceptTerminal-*-linux-x64-setup.run | |
| fincept-qt/build/FinceptTerminal-*-linux-x64-setup.run.zsync | |
| retention-days: 30 | |
| # ═════════════════════════════════════════════════════════════════════════════ | |
| # JOB 3 — macOS arm64 Installer (DMG via QtIFW or hdiutil fallback) | |
| # ═════════════════════════════════════════════════════════════════════════════ | |
| build-macos: | |
| name: macOS arm64 Installer | |
| runs-on: macos-15 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: recursive | |
| fetch-depth: 1 | |
| - name: Set up ccache | |
| uses: hendrikmuhs/ccache-action@v1 | |
| with: | |
| key: macos-arm64-release-${{ env.QT_VERSION }} | |
| max-size: 500M | |
| - name: Install build tools | |
| run: brew install ninja cmake || brew upgrade ninja cmake || true | |
| # ── Qt ──────────────────────────────────────────────────────────────── | |
| - name: Install Qt ${{ env.QT_VERSION }} | |
| uses: jurplel/install-qt-action@v4 | |
| with: | |
| version: ${{ env.QT_VERSION }} | |
| arch: clang_64 | |
| modules: ${{ env.QT_MODULES }} | |
| cache: true | |
| cache-key-prefix: qt-macos-arm64-release | |
| - name: Install Python 3.12 | |
| run: brew install python@3.12 || brew upgrade python@3.12 || true | |
| # ── Icon ────────────────────────────────────────────────────────────── | |
| - name: Generate .icns icon | |
| working-directory: fincept-qt | |
| run: | | |
| ICO="resources/fincept.ico" | |
| ICNS="resources/fincept.icns" | |
| if [ -f "${ICNS}" ]; then | |
| echo ".icns already exists" | |
| elif [ -f "${ICO}" ]; then | |
| ICONSET="$(mktemp -d)/fincept.iconset" | |
| mkdir -p "${ICONSET}" | |
| sips -s format png "${ICO}" --out "${ICONSET}/icon_512x512.png" \ | |
| --resampleHeightWidth 512 512 2>/dev/null | |
| for size in 16 32 64 128 256; do | |
| sips -z ${size} ${size} "${ICONSET}/icon_512x512.png" \ | |
| --out "${ICONSET}/icon_${size}x${size}.png" 2>/dev/null | |
| done | |
| cp "${ICONSET}/icon_512x512.png" "${ICONSET}/icon_256x256@2x.png" | |
| sips -z 1024 1024 "${ICONSET}/icon_512x512.png" \ | |
| --out "${ICONSET}/icon_512x512@2x.png" 2>/dev/null | |
| sips -z 64 64 "${ICONSET}/icon_512x512.png" \ | |
| --out "${ICONSET}/icon_32x32@2x.png" 2>/dev/null | |
| sips -z 32 32 "${ICONSET}/icon_512x512.png" \ | |
| --out "${ICONSET}/icon_16x16@2x.png" 2>/dev/null | |
| sips -z 256 256 "${ICONSET}/icon_512x512.png" \ | |
| --out "${ICONSET}/icon_128x128@2x.png" 2>/dev/null | |
| iconutil -c icns -o "${ICNS}" "${ICONSET}" | |
| echo "Generated ${ICNS}" | |
| else | |
| echo "WARNING: No source icon — app will use generic icon" | |
| fi | |
| # ── Configure + Build ───────────────────────────────────────────────── | |
| # No FINCEPT_BUILD_INSTALLER on macOS — we ship a DMG built from the | |
| # post-processed bundle (see "Generate Installer (DMG)" step below). | |
| - name: Configure CMake | |
| working-directory: fincept-qt | |
| run: | | |
| cmake -B build -G Ninja \ | |
| -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} \ | |
| -DCMAKE_PREFIX_PATH="${QT_ROOT_DIR}" \ | |
| -DCMAKE_OSX_ARCHITECTURES=arm64 \ | |
| -DCMAKE_OSX_DEPLOYMENT_TARGET=13.0 \ | |
| -DCMAKE_C_COMPILER_LAUNCHER=ccache \ | |
| -DCMAKE_CXX_COMPILER_LAUNCHER=ccache | |
| - name: Build | |
| working-directory: fincept-qt | |
| run: cmake --build build --config ${{ env.BUILD_TYPE }} -j $(sysctl -n hw.logicalcpu) | |
| # ── Verify ─────────────────────────────────────────────────────────── | |
| - name: Verify binary | |
| working-directory: fincept-qt | |
| run: | | |
| if [ -f "build/${{ env.APP_NAME }}" ]; then | |
| BINARY="build/${{ env.APP_NAME }}" | |
| elif [ -f "build/${{ env.APP_NAME }}.app/Contents/MacOS/${{ env.APP_NAME }}" ]; then | |
| BINARY="build/${{ env.APP_NAME }}.app/Contents/MacOS/${{ env.APP_NAME }}" | |
| else | |
| echo "::error::Binary not found" | |
| find build -maxdepth 3 -name "${{ env.APP_NAME }}*" || true | |
| exit 1 | |
| fi | |
| echo "Binary: $BINARY ($(du -sh "$BINARY" | cut -f1))" | |
| lipo -info "$BINARY" | |
| # ── Stage .app bundle contents (Python + resources FIRST) ───────────── | |
| # Order is critical: Python.framework and resources must be in the bundle | |
| # BEFORE macdeployqt / codesign run. If we embed after signing, dyld on | |
| # macOS 14+ kills the process with SIGKILL (Code Signature Invalid) | |
| # because the added framework pages have no signature. See issue #139. | |
| - name: Stage .app bundle contents | |
| working-directory: fincept-qt | |
| run: | | |
| set -euo pipefail | |
| BUILD_DIR="build" | |
| if [ -d "${BUILD_DIR}/${{ env.APP_NAME }}.app" ]; then | |
| APP_BUNDLE="${BUILD_DIR}/${{ env.APP_NAME }}.app" | |
| else | |
| APP_BUNDLE="${BUILD_DIR}/${{ env.APP_NAME }}.app" | |
| mkdir -p "${APP_BUNDLE}/Contents/MacOS" | |
| cp "${BUILD_DIR}/${{ env.APP_NAME }}" "${APP_BUNDLE}/Contents/MacOS/${{ env.APP_NAME }}" | |
| chmod +x "${APP_BUNDLE}/Contents/MacOS/${{ env.APP_NAME }}" | |
| cat > "${APP_BUNDLE}/Contents/Info.plist" <<'PLIST' | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"><dict> | |
| <key>CFBundleExecutable</key><string>FinceptTerminal</string> | |
| <key>CFBundleIdentifier</key><string>com.fincept.terminal</string> | |
| <key>CFBundleName</key><string>FinceptTerminal</string> | |
| <key>CFBundlePackageType</key><string>APPL</string> | |
| <key>CFBundleShortVersionString</key><string>4.0.2</string> | |
| <key>CFBundleVersion</key><string>4.0.2</string> | |
| <key>LSMinimumSystemVersion</key><string>13.0</string> | |
| <key>NSHighResolutionCapable</key><true/> | |
| </dict></plist> | |
| PLIST | |
| fi | |
| # Note: CMake's POST_BUILD rule (CMakeLists.txt line 1907) copies | |
| # scripts/ to Contents/MacOS/scripts/. This is REQUIRED — at runtime | |
| # PythonRunner::find_scripts_dir() searches paths relative to | |
| # applicationDirPath() (which is Contents/MacOS on macOS). Moving | |
| # scripts to Contents/Resources/ would break script discovery. | |
| # codesign can handle files in MacOS/; what it CANNOT handle is a | |
| # malformed framework (see the Python.framework block below). | |
| # Embed Python framework (before codesign so it gets signed with the bundle) | |
| PY_FRAMEWORK="" | |
| for candidate in \ | |
| "/Library/Frameworks/Python.framework" \ | |
| "$(brew --prefix python@3.12 2>/dev/null)/Frameworks/Python.framework" \ | |
| "$(brew --prefix python3 2>/dev/null)/Frameworks/Python.framework"; do | |
| [ -d "${candidate}/Versions" ] && { PY_FRAMEWORK="${candidate}"; break; } | |
| done | |
| BUNDLE_FRAMEWORKS="${APP_BUNDLE}/Contents/Frameworks" | |
| mkdir -p "${BUNDLE_FRAMEWORKS}" | |
| if [ -n "${PY_FRAMEWORK}" ]; then | |
| echo "Embedding Python.framework from: ${PY_FRAMEWORK}" | |
| # CRITICAL: preserve symlinks. `Python.framework` is a versioned | |
| # framework — Versions/Current → 3.12, top-level Python/Resources/ | |
| # Headers → Versions/Current/Python etc. If we dereference symlinks | |
| # (rsync --copy-unsafe-links, or `cp -RL`) the directory structure | |
| # is no longer a valid framework bundle and codesign rejects it with | |
| # "embedded framework contains modified or invalid version". | |
| # `cp -R` on macOS preserves symlinks by default; `ditto` is the | |
| # Apple-blessed way to copy bundles losslessly (preserves xattr, | |
| # symlinks, resource forks). | |
| if [ -d "${BUNDLE_FRAMEWORKS}/Python.framework" ]; then | |
| rm -rf "${BUNDLE_FRAMEWORKS}/Python.framework" | |
| fi | |
| ditto "${PY_FRAMEWORK}" "${BUNDLE_FRAMEWORKS}/Python.framework" | |
| # Strip dev-only files that shouldn't ship AND that confuse codesign: | |
| # - .pyc caches (regenerated at runtime, and old hashes fail seal) | |
| # - __pycache__ directories | |
| # - .a / .la static libs (not needed at runtime) | |
| # - test/ directories inside stdlib (huge, not needed) | |
| find "${BUNDLE_FRAMEWORKS}/Python.framework" -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true | |
| find "${BUNDLE_FRAMEWORKS}/Python.framework" -name "*.pyc" -delete 2>/dev/null || true | |
| find "${BUNDLE_FRAMEWORKS}/Python.framework" -name "*.pyo" -delete 2>/dev/null || true | |
| # Remove dangling symlinks (PrivateHeaders often points to missing targets) | |
| find "${BUNDLE_FRAMEWORKS}/Python.framework" -type l \ | |
| ! -exec test -e {} \; -delete 2>/dev/null || true | |
| PY_VER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" | |
| ln -sf "../Frameworks/Python.framework/Versions/${PY_VER}/bin/python${PY_VER}" \ | |
| "${APP_BUNDLE}/Contents/MacOS/python3" 2>/dev/null || true | |
| echo "Python ${PY_VER} embedded" | |
| # Sanity check: verify the framework structure is intact before we | |
| # hand it to codesign. A valid framework has Versions/Current as a | |
| # symlink and the top-level Python binary as a symlink to Versions/ | |
| # Current/Python. If either is a real file or directory, codesign | |
| # will reject it. | |
| if [ ! -L "${BUNDLE_FRAMEWORKS}/Python.framework/Versions/Current" ]; then | |
| echo "::error::Python.framework/Versions/Current is not a symlink — codesign will reject" | |
| ls -la "${BUNDLE_FRAMEWORKS}/Python.framework/Versions/" | |
| exit 1 | |
| fi | |
| else | |
| echo "WARNING: Python.framework not found" | |
| fi | |
| # Resources (icons, requirements-*.txt) go in Contents/Resources/ — | |
| # macOS convention for non-code assets, read via QStandardPaths. | |
| # Scripts are ALREADY in Contents/MacOS/scripts/ via CMake POST_BUILD; | |
| # do not duplicate them into Resources/ (doubles bundle size and | |
| # creates two trees for codesign to hash). | |
| mkdir -p "${APP_BUNDLE}/Contents/Resources" | |
| cp -r resources "${APP_BUNDLE}/Contents/Resources/" 2>/dev/null || true | |
| echo "Bundle staged at: ${APP_BUNDLE}" | |
| echo "Pre-sign size: $(du -sh "${APP_BUNDLE}" | cut -f1)" | |
| echo "=== Contents/MacOS/ top level ===" | |
| ls -la "${APP_BUNDLE}/Contents/MacOS/" | head -10 | |
| echo "=== Scripts count under MacOS/scripts ===" | |
| find "${APP_BUNDLE}/Contents/MacOS/scripts" -type f 2>/dev/null | wc -l || true | |
| # ── Run macdeployqt on the fully staged bundle ──────────────────────── | |
| # macdeployqt copies Qt frameworks, fixes rpaths, and signs everything | |
| # it touches. It must run AFTER Python.framework + resources are in place | |
| # so those get correct rpath treatment. | |
| - name: Run macdeployqt | |
| working-directory: fincept-qt | |
| run: | | |
| set -euo pipefail | |
| APP_BUNDLE="build/${{ env.APP_NAME }}.app" | |
| MACDEPLOYQT="${QT_ROOT_DIR}/bin/macdeployqt" | |
| if [ -x "${MACDEPLOYQT}" ]; then | |
| echo "Running macdeployqt on ${APP_BUNDLE}" | |
| # -always-overwrite keeps incremental runs idempotent on local | |
| # re-runs without affecting CI (which always starts clean). | |
| "${MACDEPLOYQT}" "${APP_BUNDLE}" -verbose=1 -always-overwrite || \ | |
| echo "::warning::macdeployqt exited non-zero — continuing to explicit codesign" | |
| else | |
| echo "::error::macdeployqt not found at ${MACDEPLOYQT} — bundle will be missing Qt frameworks" | |
| exit 1 | |
| fi | |
| # Belt-and-suspenders: if any linked dylib is still pointing outside | |
| # the bundle (e.g. @rpath to /Users/runner/work/... or /opt/homebrew), | |
| # the app will crash on any other machine. Copy every non-system | |
| # non-bundled dylib into Frameworks/ and rewrite its install name. | |
| FRAMEWORKS="${APP_BUNDLE}/Contents/Frameworks" | |
| MAIN_BIN="${APP_BUNDLE}/Contents/MacOS/${{ env.APP_NAME }}" | |
| mkdir -p "${FRAMEWORKS}" | |
| # Walk every Mach-O inside the bundle. For each, inspect its linked | |
| # libraries; if any resolve to a path that isn't a system location | |
| # (/usr/lib, /System) and isn't already inside the bundle, copy it | |
| # in and rewrite the link with install_name_tool. | |
| bundle_external_deps() { | |
| local target="$1" | |
| otool -L "$target" 2>/dev/null | tail -n +2 | awk '{print $1}' | while read -r dep; do | |
| case "$dep" in | |
| /usr/lib/*|/System/*|@executable_path/*|@loader_path/*|@rpath/*) ;; | |
| "") ;; | |
| *) | |
| if [ -f "$dep" ]; then | |
| base=$(basename "$dep") | |
| if [ ! -f "${FRAMEWORKS}/${base}" ]; then | |
| cp "$dep" "${FRAMEWORKS}/" | |
| chmod u+w "${FRAMEWORKS}/${base}" | |
| install_name_tool -id "@rpath/${base}" "${FRAMEWORKS}/${base}" 2>/dev/null || true | |
| echo "Bundled missing dep: ${base}" | |
| fi | |
| install_name_tool -change "$dep" "@rpath/${base}" "$target" 2>/dev/null || true | |
| fi | |
| ;; | |
| esac | |
| done | |
| } | |
| # Main binary + every dylib in Frameworks | |
| bundle_external_deps "${MAIN_BIN}" | |
| find "${FRAMEWORKS}" -name "*.dylib" -type f 2>/dev/null | while read -r dylib; do | |
| bundle_external_deps "$dylib" | |
| done | |
| # Verify no un-bundled non-system deps remain. | |
| LEAKS=$(otool -L "${MAIN_BIN}" 2>/dev/null | tail -n +2 | awk '{print $1}' | \ | |
| grep -vE '^(/usr/lib|/System|@executable_path|@loader_path|@rpath)' | \ | |
| grep -v '^$' || true) | |
| if [ -n "${LEAKS}" ]; then | |
| echo "::warning::Main binary still references external paths:" | |
| echo "${LEAKS}" | |
| else | |
| echo "Main binary: no external path references" | |
| fi | |
| echo "=== Bundle Frameworks/ contents ===" | |
| ls -la "${FRAMEWORKS}" | head -50 | |
| # ── Deep ad-hoc re-sign the entire bundle ───────────────────────────── | |
| # This is the fix for issue #139. macdeployqt's signing of nested | |
| # frameworks is unreliable (known Qt bug — see GDATASoftwareAG fork). | |
| # We strip every existing signature and re-sign the whole bundle from | |
| # leaves to root with a single identity (`-` = ad-hoc). On macOS 14+ | |
| # on Apple Silicon this is what prevents dyld from killing the process | |
| # with SIGKILL Code Signature Invalid at launch. | |
| # | |
| # Until Apple Developer ID cert + notarization is added (Tier 2), | |
| # users will still see "unidentified developer" on first launch and | |
| # must right-click → Open (or `xattr -d com.apple.quarantine`). | |
| - name: Deep ad-hoc codesign bundle | |
| working-directory: fincept-qt | |
| run: | | |
| set -euo pipefail | |
| APP_BUNDLE="build/${{ env.APP_NAME }}.app" | |
| echo "Removing existing signatures (leaves first)..." | |
| # Strip signatures from every Mach-O in the bundle. Must be done | |
| # leaf-to-root so nested frameworks are unsigned before we re-sign | |
| # their parents. | |
| find "${APP_BUNDLE}" -type f \( -name "*.dylib" -o -name "*.so" -o -perm +111 \) 2>/dev/null | \ | |
| while read -r f; do | |
| if file "$f" 2>/dev/null | grep -q "Mach-O"; then | |
| codesign --remove-signature "$f" 2>/dev/null || true | |
| fi | |
| done | |
| codesign --remove-signature "${APP_BUNDLE}" 2>/dev/null || true | |
| echo "Re-signing bundle ad-hoc (deep)..." | |
| # Ad-hoc sign ("-"): do NOT pass --preserve-metadata — the bundle has | |
| # no prior entitlements/requirements to preserve, and the flag will | |
| # fail with "resource fork, Finder information, or similar detritus | |
| # not allowed" on fresh macdeployqt output. Entitlements are only | |
| # needed with a real Developer ID cert + hardened runtime (Tier 2). | |
| codesign --force --deep --sign - \ | |
| --timestamp=none \ | |
| "${APP_BUNDLE}" | |
| echo "Verifying signature..." | |
| codesign --verify --deep --strict --verbose=2 "${APP_BUNDLE}" || { | |
| echo "::error::Bundle verification failed" | |
| codesign --display --deep --verbose=4 "${APP_BUNDLE}" || true | |
| exit 1 | |
| } | |
| echo "Signature OK:" | |
| codesign --display --verbose=2 "${APP_BUNDLE}" 2>&1 | head -20 | |
| # ── Copy signed bundle to dist/ ─────────────────────────────────────── | |
| - name: Finalize dist | |
| working-directory: fincept-qt | |
| run: | | |
| set -euo pipefail | |
| mkdir -p dist | |
| cp -R "build/${{ env.APP_NAME }}.app" "dist/" | |
| echo "Final bundle size: $(du -sh "dist/${{ env.APP_NAME }}.app" | cut -f1)" | |
| # ── Generate Installer (DMG via hdiutil) ────────────────────────────── | |
| # We intentionally skip CPack IFW on macOS: CPack's install(TARGETS ... | |
| # BUNDLE) rule packages the raw build-tree .app, NOT the post-processed | |
| # one we just ran macdeployqt + deep codesign over. Shipping the raw | |
| # bundle produces the dyld SIGKILL "Code Signature Invalid" crash | |
| # tracked in issue #139. A plain hdiutil DMG over `dist/` (which | |
| # contains the signed bundle) gives users a working drag-to-Apps DMG. | |
| - name: Generate Installer (DMG) | |
| working-directory: fincept-qt | |
| run: | | |
| set -euo pipefail | |
| # Parse project() VERSION so the DMG filename matches the regex in | |
| # update-manifest: FinceptTerminal-<version>-macos-arm64-setup.dmg | |
| VERSION=$(grep -Eo 'project\(FinceptTerminal VERSION [0-9]+\.[0-9]+\.[0-9]+' CMakeLists.txt | awk '{print $3}') | |
| [ -n "${VERSION}" ] || { echo "::error::Could not parse VERSION from CMakeLists.txt"; exit 1; } | |
| DMG_NAME="FinceptTerminal-${VERSION}-macos-arm64-setup.dmg" | |
| echo "DMG target: ${DMG_NAME}" | |
| STAGING="$(mktemp -d)/dmg_staging" | |
| mkdir -p "${STAGING}" | |
| cp -R "dist/${{ env.APP_NAME }}.app" "${STAGING}/" | |
| # Symlink /Applications so users can drag the app across in the DMG. | |
| ln -s /Applications "${STAGING}/Applications" | |
| hdiutil create \ | |
| -volname "${{ env.APP_NAME }}" \ | |
| -srcfolder "${STAGING}" \ | |
| -ov -format UDZO \ | |
| "build/${DMG_NAME}" | |
| ls -lh "build/${DMG_NAME}" | |
| # ── Upload ──────────────────────────────────────────────────────────── | |
| - name: Upload artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: FinceptTerminal-macOS-arm64-installer | |
| path: fincept-qt/build/FinceptTerminal-*-macos-arm64-setup.dmg | |
| retention-days: 30 | |
| # ═════════════════════════════════════════════════════════════════════════════ | |
| # JOB 4 — Create GitHub Release and attach all installers | |
| # Only runs on tag push after all builds succeed | |
| # ═════════════════════════════════════════════════════════════════════════════ | |
| release: | |
| name: Create GitHub Release | |
| needs: [build-windows, build-linux, build-macos] | |
| runs-on: ubuntu-latest | |
| if: >- | |
| !cancelled() && | |
| needs.build-windows.result == 'success' && | |
| needs.build-linux.result == 'success' && | |
| needs.build-macos.result == 'success' && | |
| startsWith(github.ref, 'refs/tags/') | |
| permissions: | |
| contents: write | |
| outputs: | |
| # Pass the tag so update-readme can use it | |
| tag: ${{ github.ref_name }} | |
| steps: | |
| - name: Download Windows installer | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: FinceptTerminal-Windows-x64-installer | |
| path: dist/windows | |
| - name: Download Linux installer | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: FinceptTerminal-Linux-x64-installer | |
| path: dist/linux | |
| - name: Download macOS installer | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: FinceptTerminal-macOS-arm64-installer | |
| path: dist/macos | |
| - name: List release assets | |
| run: find dist -type f | sort | |
| - name: Create GitHub Release and attach installers | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ github.ref_name }} | |
| name: "Fincept Terminal ${{ github.ref_name }}" | |
| body: | | |
| ## Fincept Terminal ${{ github.ref_name }} | |
| Native C++20 financial intelligence terminal — Qt6, embedded Python analytics, 50+ screens. | |
| ### Downloads | |
| | Platform | File | Notes | | |
| |----------|------|-------| | |
| | **Windows x64** | `FinceptTerminal-*-setup*.exe` | Run installer → `FinceptTerminal.exe` | | |
| | **Linux x64** | `FinceptTerminal-*.run` | `chmod +x` → run installer | | |
| | **macOS Apple Silicon** | `FinceptTerminal-*.dmg` or setup | Open DMG → drag to Applications | | |
| ### Requirements | |
| - Windows 10/11 x64 | |
| - Ubuntu 20.04+ / Debian 11+ | |
| - macOS 13+ (Apple Silicon) | |
| See the [README](https://github.com/Fincept-Corporation/FinceptTerminal#readme) for full installation instructions. | |
| files: | | |
| dist/windows/** | |
| dist/linux/** | |
| dist/macos/** | |
| fail_on_unmatched_files: false | |
| draft: false | |
| prerelease: ${{ contains(github.ref_name, '-') }} | |
| # ═════════════════════════════════════════════════════════════════════════════ | |
| # JOB 5 — Update README with direct download links | |
| # Runs after release, uses gh CLI to get actual asset URLs | |
| # ═════════════════════════════════════════════════════════════════════════════ | |
| update-readme: | |
| name: Update README download links | |
| needs: release | |
| runs-on: ubuntu-latest | |
| if: >- | |
| !cancelled() && | |
| needs.release.result == 'success' && | |
| startsWith(github.ref, 'refs/tags/') | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| # Use a token with write access so we can push the commit back | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| fetch-depth: 1 | |
| # Checkout main branch (not the tag) so we can push a commit | |
| ref: main | |
| # ── Fetch release asset URLs via gh CLI ─────────────────────────────── | |
| # gh is pre-installed on all GitHub-hosted runners. | |
| # We query the release created in the previous job and extract | |
| # each installer's browser_download_url. | |
| - name: Fetch release asset URLs | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| echo "Fetching asset URLs for release ${TAG}..." | |
| # Poll up to 60s for the release to be fully published | |
| for i in $(seq 1 12); do | |
| ASSETS=$(gh release view "${TAG}" --json assets --jq '.assets' 2>/dev/null || echo "[]") | |
| COUNT=$(echo "$ASSETS" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0") | |
| echo " attempt ${i}: ${COUNT} asset(s) found" | |
| [ "$COUNT" -ge 1 ] && break | |
| sleep 5 | |
| done | |
| # Extract URLs for each platform (first match per pattern) | |
| WIN_URL=$(gh release view "${TAG}" --json assets \ | |
| --jq '.assets[] | select(.name | test("(?i)windows.*\\.exe$|setup.*\\.exe$")) | .url' \ | |
| | head -1) | |
| LIN_URL=$(gh release view "${TAG}" --json assets \ | |
| --jq '.assets[] | select(.name | test("\\.run$|linux.*setup")) | .url' \ | |
| | head -1) | |
| MAC_URL=$(gh release view "${TAG}" --json assets \ | |
| --jq '.assets[] | select(.name | test("\\.dmg$|macos.*setup")) | .url' \ | |
| | head -1) | |
| # Fallback: if browser_download_url not available via .url, try .browserDownloadUrl | |
| [ -z "$WIN_URL" ] && WIN_URL=$(gh release view "${TAG}" --json assets \ | |
| --jq '.assets[] | select(.name | test("(?i)windows.*\\.exe$|setup.*\\.exe$")) | .browserDownloadUrl' \ | |
| | head -1) || true | |
| [ -z "$LIN_URL" ] && LIN_URL=$(gh release view "${TAG}" --json assets \ | |
| --jq '.assets[] | select(.name | test("\\.run$|linux.*setup")) | .browserDownloadUrl' \ | |
| | head -1) || true | |
| [ -z "$MAC_URL" ] && MAC_URL=$(gh release view "${TAG}" --json assets \ | |
| --jq '.assets[] | select(.name | test("\\.dmg$|macos.*setup")) | .browserDownloadUrl' \ | |
| | head -1) || true | |
| # Release page URL as final fallback | |
| RELEASE_URL="https://github.com/Fincept-Corporation/FinceptTerminal/releases/tag/${TAG}" | |
| WIN_URL="${WIN_URL:-${RELEASE_URL}}" | |
| LIN_URL="${LIN_URL:-${RELEASE_URL}}" | |
| MAC_URL="${MAC_URL:-${RELEASE_URL}}" | |
| echo "WIN_URL=${WIN_URL}" >> "$GITHUB_ENV" | |
| echo "LIN_URL=${LIN_URL}" >> "$GITHUB_ENV" | |
| echo "MAC_URL=${MAC_URL}" >> "$GITHUB_ENV" | |
| echo "RELEASE_URL=${RELEASE_URL}" >> "$GITHUB_ENV" | |
| echo "TAG=${TAG}" >> "$GITHUB_ENV" | |
| echo "Windows : ${WIN_URL}" | |
| echo "Linux : ${LIN_URL}" | |
| echo "macOS : ${MAC_URL}" | |
| # ── Rewrite the download table in README.md ─────────────────────────── | |
| # Strategy: sed-replace a clearly-delimited block between two sentinel | |
| # comments. This is the most robust approach — no regex on links needed. | |
| # The sentinels already exist in the README (added below by this step on | |
| # first run; subsequent runs just overwrite between them). | |
| - name: Update README download table | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| # Sentinel markers in README — we replace everything between them. | |
| START_MARKER="<!-- DOWNLOAD-TABLE-START -->" | |
| END_MARKER="<!-- DOWNLOAD-TABLE-END -->" | |
| NEW_BLOCK="${START_MARKER} | |
| ### Option 1 — Download Installer (Recommended) | |
| Latest release: **${TAG}** — [View all releases](${RELEASE_URL}) | |
| | Platform | Download | Run | | |
| |----------|----------|-----| | |
| | **Windows x64** | [FinceptTerminal-Windows-x64-setup.exe](${WIN_URL}) | Run installer → launch \`FinceptTerminal.exe\` | | |
| | **Linux x64** | [FinceptTerminal-Linux-x64.run](${LIN_URL}) | \`chmod +x\` → run installer | | |
| | **macOS Apple Silicon** | [FinceptTerminal-macOS-arm64.dmg](${MAC_URL}) | Open DMG → drag to Applications | | |
| ${END_MARKER}" | |
| README="README.md" | |
| # Delegate the regex replacement to a standalone Python script so | |
| # YAML indentation can't poison the Python source (an in-line | |
| # heredoc body would be parsed by Python with leading spaces and | |
| # fail with IndentationError). | |
| export README NEW_BLOCK | |
| python3 .github/scripts/update_readme_table.py | |
| echo "=== README diff preview (first 60 lines) ===" | |
| git diff README.md | head -60 || true | |
| # ── Commit and push ─────────────────────────────────────────────────── | |
| - name: Commit and push README update | |
| run: | | |
| set -euo pipefail | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| if git diff --quiet README.md; then | |
| echo "README unchanged — nothing to commit" | |
| exit 0 | |
| fi | |
| git add README.md | |
| git commit -m "docs: update download links for ${TAG} | |
| Auto-updated by release workflow after successful build. | |
| Windows, Linux, macOS installer URLs reflect the ${TAG} release. | |
| Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>" | |
| git push origin main | |
| echo "README pushed to main" | |
| # ═════════════════════════════════════════════════════════════════════════════ | |
| # JOB 6 — Regenerate updates.json (auto-updater manifest) | |
| # | |
| # Reads the published GitHub Release, computes sha256 for each installer | |
| # asset, writes a per-platform/per-arch manifest that the in-app updater | |
| # consumes (see fincept-qt/src/services/updater/UpdateService.cpp). | |
| # | |
| # Platform key scheme — must match UpdateService::current_platform_key(): | |
| # windows-x64 / windows-arm64 / linux-x64 / macos-arm64 / macos-x64 | |
| # | |
| # Asset filename scheme — must match CPACK_PACKAGE_FILE_NAME in CMakeLists.txt: | |
| # FinceptTerminal-<version>-<platform>-<arch>-setup.<ext> | |
| # ═════════════════════════════════════════════════════════════════════════════ | |
| update-manifest: | |
| name: Regenerate updates.json | |
| needs: release | |
| runs-on: ubuntu-latest | |
| if: >- | |
| !cancelled() && | |
| needs.release.result == 'success' && | |
| startsWith(github.ref, 'refs/tags/') | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| fetch-depth: 1 | |
| ref: main | |
| - name: Regenerate updates.json from release assets | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ github.ref_name }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| # Strip leading 'v' from tag so "v4.0.2" → "4.0.2" for latest-version | |
| VERSION="${TAG#v}" | |
| RELEASE_URL="https://github.com/${REPO}/releases/tag/${TAG}" | |
| # Poll up to 60s for the release to be fully published | |
| for i in $(seq 1 12); do | |
| if gh release view "${TAG}" --json assets >/dev/null 2>&1; then | |
| break | |
| fi | |
| echo " attempt ${i}: release not yet visible, sleeping..." | |
| sleep 5 | |
| done | |
| WORK="$(mktemp -d)" | |
| echo "Downloading release assets to ${WORK}..." | |
| gh release download "${TAG}" --dir "${WORK}" --pattern "FinceptTerminal-*setup*" || true | |
| # Export shell-side vars so the embedded python can read them via os.environ | |
| export WORK VERSION TAG REPO | |
| # Delegate manifest generation to a standalone Python script to | |
| # keep this step free of YAML/Python indentation conflicts. The | |
| # script writes JSON to stdout and diagnostics to stderr; we redirect | |
| # stdout into updates.json and let stderr surface in the job log. | |
| python3 .github/scripts/generate_updates_manifest.py > updates.json | |
| echo "=== updates.json diff ===" | |
| git diff updates.json | head -80 || true | |
| - name: Commit and push updates.json | |
| env: | |
| TAG: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| if git diff --quiet updates.json; then | |
| echo "updates.json unchanged — nothing to commit" | |
| exit 0 | |
| fi | |
| git add updates.json | |
| git commit -m "chore(release): regenerate updates.json for ${TAG} | |
| Auto-generated by release workflow. sha256 computed from release assets. | |
| Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>" | |
| git push origin main | |
| echo "updates.json pushed to main" |