Files
Stirling-PDF/.github/workflows/multiOSReleases.yml
T
Ludy ec3de16c08 ci: centralize Gradle caching across GitHub Actions workflows (#7546)
## Summary

This pull request restructures Gradle dependency caching across the
GitHub Actions workflows.

The central `gradle-cache-prime` job is responsible for preparing the
shared backend Gradle cache. Reusable workflows restore that shared
cache without writing to the same key, while independently triggered
workflows use isolated cache namespaces.

## What changed

### Shared Gradle cache

- Added a stable `gradle-v1-` cache namespace for the shared backend
cache.
- The cache key includes the runner OS, runner architecture, JDK
version, and the relevant Gradle configuration files.
- The cache key is calculated before Gradle runs and reused for the
later save step.
- The prime job performs a lookup first and resolves backend
dependencies only when the exact cache is missing.
- This prevents Gradle or Spotless changes during the prime step from
producing a different save key from the key used by downstream jobs.

### Reusable workflows

- Backend, OpenAPI, license, Docker, E2E, and migration workflows
restore the shared cache instead of writing to the shared key.
- The backend build matrix includes `matrix.jdk-version` in its cache
key.
- Enterprise, Tauri, and generated-model workflows support the
`use_shared_cache` boolean input.
- When `use_shared_cache` is enabled, those workflows restore the shared
cache.
- When it is disabled, they use workflow-specific cache namespaces.

### Independent workflows

Independent workflows now use separate cache prefixes, including:

- `gradle-license-report-v1-`
- `gradle-swagger-v1-`
- `gradle-push-docker-v1-`
- `gradle-tauri-releases-v1-`
- `gradle-deploy-pr-v1-`
- `gradle-playwright-e2e-v1-`
- `gradle-generated-models-v1-`

This prevents them from creating or affecting the shared backend cache
before the prime job.

### Build and E2E flow

- Removed the `-PnoSpotless` option from the central Gradle
dependency-resolution command.
- Removed the separate Gradle dependency prime/retry logic from the live
E2E workflow.
- Connected the Tauri build and generated-models check to the central
cache-prime job.

## Motivation

Previously, multiple workflows could use and save the same Gradle cache
key independently. The first workflow to save the cache could therefore
determine its contents, even if it had resolved a different or
incomplete set of dependencies.

The cache key was also evaluated after some Gradle tasks had run. If
Gradle or Spotless modified a file covered by `hashFiles(...)`, the save
key could differ from the restore key used by downstream jobs.

This change gives the shared cache a single owner, isolates
workflow-specific caches, and makes cache usage deterministic across the
CI pipeline.

## Expected result

- `gradle-cache-prime` is the single writer for the shared backend
Gradle cache.
- Downstream jobs restore the same cache without competing cache writes.
- Independently triggered workflows remain isolated through their own
cache namespaces.
- Changes to the monitored Gradle configuration files produce a new
cache key.
- The normal Gradle/Spotless path is included when the shared cache is
populated.

## Validation

- Compared the cache key expressions and `hashFiles(...)` inputs across
the affected workflows.
- Verified that the central restore and save steps use the same
precomputed key.
- CI should confirm that the prime job populates the shared cache and
downstream workflows only restore it.

## Checklist

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have performed a self-review of my changes
- [ ] I have run the relevant CI checks
- [ ] I have tested the workflow changes
2026-08-18 19:25:55 +01:00

902 lines
43 KiB
YAML

name: Multi-OS Tauri Releases
on:
workflow_dispatch:
inputs:
test_mode:
description: "Run in test mode (skip release step)"
required: false
default: "true"
type: choice
options:
- "true"
- "false"
platform:
description: "Platform to build (windows, windows-arm64, macos, linux, or all)"
required: true
default: "all"
type: choice
options:
- all
- windows
- windows-arm64
- macos
- linux
sign:
description: "Code sign the binaries (requires signing secrets)"
required: false
default: "true"
type: choice
options:
- "true"
- "false"
release:
types: [created]
permissions:
contents: read
jobs:
determine-matrix:
environment: ci-unsigned
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Get version number
id: versionNumber
run: |
VERSION=$(./gradlew printVersion --quiet | tail -1)
echo "Extracted version: $VERSION"
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Determine build matrix
id: set-matrix
run: |
# windows-arm64: NSIS only (WiX MSI has no arm64 support in Tauri) and no
# JPDFium natives yet - flip to windows-arm64 once JPDFium ships them.
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}'
WINDOWS_ARM64='{"platform":"windows-11-arm","args":"--target aarch64-pc-windows-msvc --bundles nsis","name":"windows-arm64","jpdfium_platforms":"none"}'
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}'
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}'
ALL="$WINDOWS,$WINDOWS_ARM64,$MACOS,$LINUX"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
case "${INPUT_PLATFORM}" in
"windows")
echo "matrix={\"include\":[$WINDOWS,$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT
;;
"windows-arm64")
echo "matrix={\"include\":[$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT
;;
"macos")
echo "matrix={\"include\":[$MACOS]}" >> $GITHUB_OUTPUT
;;
"linux")
echo "matrix={\"include\":[$LINUX]}" >> $GITHUB_OUTPUT
;;
*)
echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT
;;
esac
else
# For push/release events, build all platforms
echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT
fi
env:
INPUT_PLATFORM: ${{ github.event.inputs.platform }}
build-jars:
environment: ci-unsigned
needs: determine-matrix
runs-on: ubuntu-latest
strategy:
matrix:
variant:
- name: "default"
disable_security: true
build_frontend: true
file_suffix: ""
- name: "with-login"
disable_security: false
build_frontend: true
file_suffix: "-with-login"
- name: "server-only"
disable_security: true
build_frontend: false
file_suffix: "-server"
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Node.js
if: matrix.variant.build_frontend == true
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Build JAR
run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Rename JAR
run: |
echo "Version from determine-matrix: ${{ needs.determine-matrix.outputs.version }}"
echo "Looking for: app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar"
ls -la app/core/build/libs/
mkdir -p ./jar-dist
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
- name: Upload JAR artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: jar${{ matrix.variant.file_suffix }}
path: ./jar-dist/*.jar
retention-days: 1
build:
environment: release-signing
needs: determine-matrix
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
runs-on: ${{ matrix.platform }}
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
allowed-endpoints: >
one.digicert.com:443
clientauth.one.digicert.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
with:
toolchain: stable
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
# x86_64 JDK is set up first so the aarch64 step below can leave its
# JAVA_HOME as the active one. The macOS universal JRE build needs
# jmods from both arches; the x64 path is captured into the env
# before the second setup-java overwrites JAVA_HOME.
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
architecture: "x64"
- name: Capture x86_64 JAVA_HOME
if: matrix.platform == 'macos-15'
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
# Build the universal JRE before desktop:prepare so the jlink:runtime
# task short-circuits on its `test -d runtime/jre` status check.
- name: Build universal macOS JRE
if: matrix.platform == 'macos-15'
env:
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
run: task desktop:jlink:universal-mac
- name: Prepare desktop build
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: true
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
run: task desktop:prepare
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
# Decode client certificate
$certBytes = [Convert]::FromBase64String("$env:SM_CLIENT_CERT_FILE_B64")
$certPath = "D:\Certificate_pkcs12.p12"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Set environment variables
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
echo "SM_HOST=$env:SM_HOST" >> $env:GITHUB_ENV
echo "SM_API_KEY=$env:SM_API_KEY" >> $env:GITHUB_ENV
echo "SM_CLIENT_CERT_PASSWORD=$env:SM_CLIENT_CERT_PASSWORD" >> $env:GITHUB_ENV
echo "SM_KEYPAIR_ALIAS=$env:SM_KEYPAIR_ALIAS" >> $env:GITHUB_ENV
# Get PKCS11 config path from DigiCert action
$pkcs11Config = $env:PKCS11_CONFIG
if ($pkcs11Config) {
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
} else {
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
if (Test-Path $defaultPath) {
Write-Host "Found config at default path: $defaultPath"
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
} else {
Write-Host "Warning: Could not find PKCS11 config file"
}
}
env:
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
SM_HOST: ${{ secrets.SM_HOST }}
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
echo "Importing Apple Developer Certificate..."
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Clean up
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
echo "Certificate Info: $CERT_INFO"
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "Certificate ID: $CERT_ID"
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported successfully."
# Pre-flight: verify smctl can talk to DigiCert and sync cert before we sign.
# Mirrors the setup from working public Tauri+KeyLocker repos (Labric, Meetily).
# Without this, signCommand failures are opaque (Tauri captures but drops
# smctl's stderr) - running these loudly surfaces auth/env/keypair issues.
- name: Preflight smctl
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
shell: pwsh
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
run: |
& smctl healthcheck
if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] smctl healthcheck failed"; exit 1 }
& smctl keypair ls
if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] smctl keypair ls failed"; exit 1 }
& smctl windows certsync --keypair-alias "$env:KEYPAIR_ALIAS"
if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" }
Write-Host "[SUCCESS] smctl preflight passed"
# Write platform-specific Tauri config that adds signCommand for Windows.
# Tauri auto-merges tauri.windows.conf.json with tauri.conf.json (RFC 7396).
# Tauri calls this command on every binary BEFORE bundling into the MSI,
# substituting %1 with the file path.
#
# Why OBJECT form (cmd + args) instead of string:
# Tauri's string-form parser does a naive split(' ') with no shell/quote handling.
# Args with spaces or quote characters get mangled. The object form passes each
# arg directly to Rust's Command::arg which handles Windows CreateProcess quoting.
#
# Why --keypair-alias instead of --fingerprint:
# --fingerprint requires smctl windows certsync to have synced the cert to the
# Windows cert store first. --keypair-alias goes direct through PKCS11 and works
# without certsync. All real-world working Tauri+smctl examples use this flag.
#
# smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD
# from env (set by prior DigiCert setup step). No --config-file needed.
- name: Configure Windows code signing
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
shell: bash
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
run: |
cat > ./frontend/editor/src-tauri/tauri.windows.conf.json <<EOF
{
"bundle": {
"windows": {
"signCommand": {
"cmd": "smctl",
"args": ["sign", "--keypair-alias", "${KEYPAIR_ALIAS}", "--input", "%1", "--verbose"]
}
}
}
}
EOF
echo "Generated tauri.windows.conf.json (alias masked):"
sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/editor/src-tauri/tauri.windows.conf.json
- name: Import release GPG signing key (Linux)
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
run: |
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
gpg --list-secret-keys --keyid-format=long
- name: Make libjvm discoverable for linuxdeploy (Linux AppImage)
if: matrix.platform == 'ubuntu-22.04'
run: |
JAVA_LIBJVM="$JAVA_HOME/lib/server/libjvm.so"
if [ -f "$JAVA_LIBJVM" ]; then
sudo ln -sf "$JAVA_LIBJVM" /usr/lib/libjvm.so
echo "Linked libjvm from $JAVA_LIBJVM -> /usr/lib/libjvm.so"
else
echo "libjvm not found at $JAVA_LIBJVM"
exit 1
fi
- name: Build Tauri app
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# AppImage signing — three env vars work together:
# SIGN=1 tells linuxdeploy-plugin-appimage to forward --sign to appimagetool
# APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively
# SIGN_KEY appimagetool picks the key matching this fingerprint
# Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present.
# Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or the release branch, when secret is present.
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
# DigiCert KeyLocker env vars consumed by smctl during signCommand
SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}
CI: true
with:
projectPath: ./frontend/editor
tauriScript: npx tauri
args: ${{ matrix.args }}
# Bundled libwayland conflicts with the host's on some distros (Fedora
# Wayland: EGL_BAD_PARAMETER, blank window - #6878). Repack without it,
# then regenerate the updater .sig (repack invalidates the original) and
# GPG-sign again when release signing is on.
- name: Strip bundled Wayland libs from AppImage
if: matrix.platform == 'ubuntu-22.04'
continue-on-error: true
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
run: |
set -euo pipefail
AI=$(find "$PWD/frontend/editor/src-tauri/target" -name "*.AppImage" | head -1)
if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi
chmod +x "$AI"
WORK=$(mktemp -d)
(cd "$WORK" && "$AI" --appimage-extract >/dev/null)
if ! ls "$WORK/squashfs-root/usr/lib/"libwayland-* >/dev/null 2>&1; then
echo "No bundled libwayland - nothing to strip"
rm -rf "$WORK"
exit 0
fi
rm -f "$WORK/squashfs-root/usr/lib/"libwayland-*
curl -fsSL -o "$WORK/appimagetool" \
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
# Pinned checksum: never execute an unverified downloaded binary. On
# mismatch (upstream rebuilt continuous) the step aborts and the
# original AppImage ships unchanged - update the pin deliberately.
echo "a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 $WORK/appimagetool" | sha256sum -c -
chmod +x "$WORK/appimagetool"
SIGN_ARGS=()
if [ "$GPG_SIGN" = "1" ] && [ -n "${SIGN_KEY:-}" ]; then
SIGN_ARGS=(--sign --sign-key "$SIGN_KEY")
fi
"$WORK/appimagetool" --appimage-extract-and-run "${SIGN_ARGS[@]}" "$WORK/squashfs-root" "$AI.new"
# Updater payload signature must match the repacked bytes. The CLI
# reads the key/password from env - never pass secrets as argv.
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
(cd frontend && npx tauri signer sign "$AI.new")
mv "$AI.new.sig" "$AI.sig"
fi
mv "$AI.new" "$AI"
rm -rf "$WORK"
echo "Stripped bundled libwayland from $(basename "$AI")"
- name: Clear release GPG key from runner keyring (Linux)
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
env:
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
run: |
if [ -n "$RELEASE_GPG_FINGERPRINT" ]; then
gpg --batch --yes --delete-secret-keys "$RELEASE_GPG_FINGERPRINT" || true
gpg --batch --yes --delete-keys "$RELEASE_GPG_FINGERPRINT" || true
fi
# Verify the MSI (outer wrapper users download) AND the inner exe extracted
# from it (what actually gets installed and what AV scans). We don't check
# target/.../release/stirling-pdf.exe - that's Tauri's intermediate build
# artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw
# cargo output unsigned, so checking it produces false negatives.
- name: Verify Windows Code Signature
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
timeout-minutes: 15
shell: pwsh
run: |
# arm64 ships an NSIS installer, not an MSI. Tauri's signCommand signs the
# inner exe before packing and the setup exe after, so verifying the setup
# exe is the arm64 equivalent of the MSI + inner-exe check below.
if ("${{ matrix.platform }}" -eq "windows-11-arm") {
$setupExes = Get-ChildItem -Path "./frontend/editor/src-tauri/target" -Filter "*-setup.exe" -Recurse -File
if ($setupExes.Count -eq 0) {
Write-Host "[ERROR] No NSIS installer found under target/"
exit 1
}
foreach ($exe in $setupExes) {
$sig = Get-AuthenticodeSignature -FilePath $exe.FullName
Write-Host "NSIS installer: $($exe.Name) Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)"
if ($sig.Status -ne "Valid") {
Write-Host "[ERROR] NSIS installer is not signed"
exit 1
}
}
Write-Host "[SUCCESS] NSIS installer is properly signed"
exit 0
}
$allSigned = $true
# Check MSI installer (outer wrapper - what users download)
$msiFiles = Get-ChildItem -Path "./frontend/editor/src-tauri/target" -Filter "*.msi" -Recurse -File
if ($msiFiles.Count -eq 0) {
Write-Host "[ERROR] No MSI found under target/"
exit 1
}
foreach ($msi in $msiFiles) {
$sig = Get-AuthenticodeSignature -FilePath $msi.FullName
Write-Host "MSI: Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)"
if ($sig.Status -ne "Valid") {
Write-Host "[ERROR] MSI is not signed"
$allSigned = $false
}
}
# Extract MSI and verify the inner exe (the file that actually gets installed).
# This is the critical check - AV flags the installed exe at runtime.
# Use lessmsi, not `msiexec /a`: msiexec serializes on the global
# _MSIExecute mutex and hangs forever on hosted runners when another
# installer is busy. lessmsi reads MSI tables directly - no mutex, no service.
$msi = $msiFiles[0].FullName
$extractDir = Join-Path $env:RUNNER_TEMP "msi-verify"
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
choco install lessmsi -y --no-progress --limit-output | Out-Null
# Bound the extraction and kill on hang (defence in depth over timeout-minutes).
$proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow
if (-not $proc.WaitForExit(120000)) {
try { $proc.Kill() } catch {}
Write-Host "[ERROR] MSI extraction timed out after 120s"
$allSigned = $false
} elseif ($proc.ExitCode -ne 0) {
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
$allSigned = $false
} else {
$innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1
if ($innerExe) {
$sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName
Write-Host "Inner EXE (from MSI): Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)"
if ($sig.Status -ne "Valid") {
Write-Host "[ERROR] Inner exe extracted from MSI is NOT signed - AV will flag this at runtime"
$allSigned = $false
}
} else {
Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI"
$allSigned = $false
}
}
if (-not $allSigned) {
Write-Host "[ERROR] Signature verification failed"
exit 1
}
Write-Host "[SUCCESS] MSI and installed exe are properly signed"
# Dump smctl log files on failure. Tauri's signCommand captures smctl output
# but drops stderr when the command exits non-zero, making failures opaque.
# The real errors live in smctl's log files - surface them here for debugging.
- name: Dump smctl logs on failure
if: ${{ failure() && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' }}
shell: pwsh
run: |
$logDir = "$env:USERPROFILE\.signingmanager\logs"
if (Test-Path $logDir) {
Get-ChildItem $logDir | ForEach-Object {
Write-Host "=== $($_.FullName) ==="
Get-Content $_.FullName -Tail 200
Write-Host ""
}
} else {
Write-Host "smctl log directory not found at $logDir"
}
# Rename + Upload: use always() so artifacts are still collected when verify
# fails - we need them to manually inspect what actually came out of the build.
- name: Rename artifacts
if: always() && steps.digicert-setup.conclusion != 'failure'
shell: bash
run: |
# Absolute dist path so the cd below can't break the copy targets.
DIST="$GITHUB_WORKSPACE/dist"
mkdir -p "$DIST"
cd ./frontend/editor/src-tauri/target
echo "=== tauri bundle artifacts ==="
find . -path "*/bundle/*" \( -name "*.msi" -o -name "*.deb" \
-o -name "*.rpm" -o -name "*.AppImage" -o -name "*.dmg" \
-o -name "*.app.tar.gz" -o -name "*.sig" \) 2>/dev/null | sort || true
echo "=============================="
# createUpdaterArtifacts:true signs the native installers in place;
# each <bundle> ships with a sibling <bundle>.sig consumed by latest.json.
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \;
find . -name "*.msi.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.sig" \;
elif [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
# arm64 ships the NSIS installer (WiX MSI has no arm64 support in Tauri).
# The setup exe is also its own updater payload (-> sibling .sig).
find . -name "*-setup.exe" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe" \;
find . -name "*-setup.exe.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe.sig" \;
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
# DMG = manual install; .app.tar.gz (+ .sig) = updater payload.
# Raw .app is intentionally not shipped (hundreds of MB of uncompressed input).
find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app.tar.gz" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.app.tar.gz" \;
find . -name "*.app.tar.gz.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.app.tar.gz.sig" \;
else
# The raw .AppImage IS its updater payload (signed -> .AppImage.sig),
# not a .tar.gz wrapper - that's only produced under v1Compatible.
find . -name "*.deb" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.deb.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb.sig" \;
find . -name "*.rpm" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm" \;
find . -name "*.rpm.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm.sig" \;
find . -name "*.AppImage" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage" \;
find . -name "*.AppImage.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage.sig" \;
fi
- name: Upload build artifacts
if: always() && steps.digicert-setup.conclusion != 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
retention-days: 1
collect-and-release:
needs: [determine-matrix, build, build-jars]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
# Sparse-check out the verifier + pubkey before the artifact downloads
# so the checkout cannot clobber ./artifacts.
- name: Checkout updater verifier
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
.github/scripts/verify-updater-signatures.py
frontend/editor/src-tauri/tauri.conf.json
sparse-checkout-cone-mode: false
- name: Download all Tauri artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: Stirling-PDF-*
path: ./artifacts/tauri
- name: Download JAR artifact (default)
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: jar
path: ./artifacts/jars
- name: Download JAR artifact (with login)
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: jar-with-login
path: ./artifacts/jars
- name: Download JAR artifact (server only)
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: jar-server
path: ./artifacts/jars
- name: Display structure of downloaded files
run: ls -R ./artifacts
# tauri-action only emits latest.json when it also publishes the release
# (tagName/releaseId set). We publish separately via action-gh-release,
# so build latest.json here from the per-platform .sig files.
- name: Generate updater latest.json
env:
VERSION: ${{ needs.determine-matrix.outputs.version }}
TAG: v${{ needs.determine-matrix.outputs.version }}
REPO: ${{ github.repository }}
run: |
python3 - << 'PYEOF'
import json, os, sys
from pathlib import Path
from datetime import datetime, timezone
VERSION = os.environ['VERSION']
TAG = os.environ['TAG']
REPO = os.environ['REPO']
ART = Path('./artifacts/tauri')
# Tauri updater looks up {os}-{arch}-{installer} (e.g. linux-x86_64-deb)
# before bare {os}-{arch}, so per-format Linux keys let deb/rpm/appimage
# each self-update from their matching file. macOS universal serves both
# arches from the one .app.tar.gz.
PLATFORM_MAP = [
{
'bundles': ['Stirling-PDF-linux-x86_64.deb'],
'targets': ['linux-x86_64-deb'],
},
{
'bundles': ['Stirling-PDF-linux-x86_64.rpm'],
'targets': ['linux-x86_64-rpm'],
},
{
'bundles': ['Stirling-PDF-linux-x86_64.AppImage'],
'targets': ['linux-x86_64-appimage'],
},
{
'bundles': ['Stirling-PDF-windows-x86_64.msi'],
'targets': ['windows-x86_64-msi', 'windows-x86_64'],
},
{
'bundles': ['Stirling-PDF-windows-arm64-setup.exe'],
'targets': ['windows-aarch64-nsis', 'windows-aarch64'],
},
{
'bundles': ['Stirling-PDF-macos-universal.app.tar.gz'],
'targets': ['darwin-x86_64', 'darwin-aarch64'],
},
]
# rglob() because download-artifact varies layout: one artifact -> flat,
# many -> nested under <artifact-name>/.
def find_signed(name):
for bundle_path in sorted(ART.rglob(name)):
sig_path = bundle_path.with_name(bundle_path.name + '.sig')
if sig_path.exists():
return bundle_path, sig_path
return None
platforms = {}
skipped = []
for entry in PLATFORM_MAP:
picked = None
for name in entry['bundles']:
picked = find_signed(name)
if picked:
break
if not picked:
skipped.append(
f"{entry['targets']} (no signed bundle among "
f"{entry['bundles']} - TAURI_SIGNING_PRIVATE_KEY unset "
f"or createUpdaterArtifacts disabled?)"
)
continue
bundle_path, sig_path = picked
signature = sig_path.read_text(encoding='utf-8').strip()
url = f"https://github.com/{REPO}/releases/download/{TAG}/{bundle_path.name}"
for target in entry['targets']:
platforms[target] = {'signature': signature, 'url': url}
print(f"Added {entry['targets']} from {bundle_path.name}")
if skipped:
print("Skipped platforms:")
for s in skipped:
print(f" - {s}")
if not platforms:
print(
"WARN: no signed updater bundles found - "
"skipping latest.json generation"
)
sys.exit(0)
manifest = {
'version': VERSION,
'notes': f"See https://github.com/{REPO}/releases/tag/{TAG}",
'pub_date': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'platforms': platforms,
}
out = Path('./artifacts/latest.json')
out.write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8')
print(f"Generated {out} with platforms: {sorted(platforms.keys())}")
PYEOF
- name: Upload merged artifacts for review
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-artifacts
path: ./artifacts/
retention-days: 7
# Gate publish on valid updater sigs. Runs after the review upload (so
# artifacts survive for debugging) and before action-gh-release.
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Verify updater signatures
run: |
uv run --project engine --locked --only-group updater-signatures python .github/scripts/verify-updater-signatures.py \
./artifacts/tauri frontend/editor/src-tauri/tauri.conf.json
# workflow_dispatch path requires platform=='all' so a single-platform
# dispatch can't overwrite an existing release's full latest.json with a
# partial one (action-gh-release defaults overwrite_files:true).
# release event / release branch always build the full matrix so no extra guard needed.
# fail_on_unmatched_files makes a missing latest.json or installer fail loudly
# instead of silently shipping a broken auto-update.
- name: Upload binaries to Release
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/release'
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
# Don't regenerate/append notes on re-runs, and don't force this into the
# "Latest" slot - leave the release body and latest marker as they are.
generate_release_notes: false
append_body: false
make_latest: false
fail_on_unmatched_files: true
# Installers + updater payloads + manifest. .sig contents are embedded
# in latest.json so the .sig files themselves are not uploaded.
files: |
./artifacts/**/*.jar
./artifacts/**/*.msi
./artifacts/**/*-setup.exe
./artifacts/**/*.dmg
./artifacts/**/*.app.tar.gz
./artifacts/**/*.deb
./artifacts/**/*.rpm
./artifacts/**/*.AppImage
./artifacts/latest.json
draft: false
prerelease: false