release-gui #23
Workflow file for this run
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-gui | |
| on: | |
| push: | |
| tags: | |
| - "v[0-9]*.[0-9]*.[0-9]*" | |
| schedule: | |
| - cron: '0 8 * * 1' | |
| workflow_dispatch: | |
| inputs: | |
| source_mode: | |
| description: Source selection for this run | |
| required: false | |
| default: release_tag | |
| type: choice | |
| options: | |
| - release_tag | |
| - current_ref | |
| tag: | |
| description: Existing tag to release in release_tag mode (for example vX.Y.Z) | |
| required: false | |
| type: string | |
| dry_run: | |
| description: Build/test only (skip publish job) | |
| required: false | |
| default: false | |
| type: boolean | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: release-gui-${{ github.event_name }}-${{ github.ref_name || github.event.inputs.tag || github.run_id }} | |
| cancel-in-progress: false | |
| jobs: | |
| resolve_tag: | |
| if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/v') | |
| runs-on: ubuntu-22.04 | |
| timeout-minutes: 10 | |
| outputs: | |
| resolved_tag: ${{ steps.resolve.outputs.resolved_tag }} | |
| source_ref: ${{ steps.resolve.outputs.source_ref }} | |
| source_mode: ${{ steps.resolve.outputs.source_mode }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| with: | |
| fetch-depth: 0 | |
| - name: Resolve source | |
| id: resolve | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| MODE="release_tag" | |
| if [[ "${GITHUB_EVENT_NAME}" == "schedule" ]]; then | |
| MODE="current_ref" | |
| elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | |
| MODE="${{ github.event.inputs.source_mode }}" | |
| if [[ -z "${MODE}" ]]; then | |
| MODE="release_tag" | |
| fi | |
| fi | |
| case "${MODE}" in | |
| release_tag) | |
| if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | |
| TAG="${{ github.event.inputs.tag }}" | |
| else | |
| TAG="${GITHUB_REF_NAME}" | |
| fi | |
| if [[ -z "${TAG}" ]]; then | |
| echo "release tag cannot be empty in release_tag mode" >&2 | |
| exit 1 | |
| fi | |
| if [[ "${TAG}" != v* ]]; then | |
| echo "release tag must start with 'v' (got: ${TAG})" >&2 | |
| exit 1 | |
| fi | |
| if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "release tag must match stable vX.Y.Z format (got: ${TAG})" >&2 | |
| exit 1 | |
| fi | |
| git fetch --force --tags origin | |
| git rev-parse "refs/tags/${TAG}" >/dev/null | |
| SOURCE_REF="refs/tags/${TAG}" | |
| ;; | |
| current_ref) | |
| WORKSPACE_VERSION="$(bash .github/scripts/workspace_version.sh)" | |
| TAG="v${WORKSPACE_VERSION}" | |
| SOURCE_REF="${GITHUB_SHA}" | |
| { | |
| echo "### current_ref mode" | |
| echo | |
| echo "Using source ref: ${SOURCE_REF}" | |
| echo "Derived packaging tag/version: ${TAG}" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| ;; | |
| *) | |
| echo "unsupported source_mode: ${MODE}" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| echo "source_mode=${MODE}" >> "$GITHUB_OUTPUT" | |
| echo "resolved_tag=${TAG}" >> "$GITHUB_OUTPUT" | |
| echo "source_ref=${SOURCE_REF}" >> "$GITHUB_OUTPUT" | |
| echo "resolved mode=${MODE} tag=${TAG} source_ref=${SOURCE_REF}" | |
| - name: Checkout resolved source | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ steps.resolve.outputs.source_ref }} | |
| - name: Validate workspace version matches tag | |
| if: steps.resolve.outputs.source_mode == 'release_tag' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| WORKSPACE_VERSION="$(bash .github/scripts/workspace_version.sh)" | |
| TAG_VERSION="${{ steps.resolve.outputs.resolved_tag }}" | |
| TAG_VERSION="${TAG_VERSION#v}" | |
| if [[ -z "${WORKSPACE_VERSION}" ]]; then | |
| echo "failed to read [workspace.package].version from Cargo.toml" >&2 | |
| exit 1 | |
| fi | |
| if [[ "${WORKSPACE_VERSION}" != "${TAG_VERSION}" ]]; then | |
| echo "tag/version mismatch: tag=${TAG_VERSION} workspace=${WORKSPACE_VERSION}" >&2 | |
| exit 1 | |
| fi | |
| smoke: | |
| needs: resolve_tag | |
| runs-on: ubuntu-22.04 | |
| timeout-minutes: 30 | |
| env: | |
| RESOLVED_TAG: ${{ needs.resolve_tag.outputs.resolved_tag }} | |
| SOURCE_REF: ${{ needs.resolve_tag.outputs.source_ref }} | |
| steps: | |
| - name: Checkout resolved source | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ env.SOURCE_REF }} | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master @ 2026-02-23 | |
| with: | |
| toolchain: 1.89.0 | |
| - name: Cache Rust build artifacts | |
| uses: swatinem/rust-cache@aa7c1c80a07a27a84c0aa76d0cef0aad3830e330 # v2.7.8 | |
| - name: Build smoke binaries | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cargo build -p localpaste_server --bin localpaste --locked | |
| cargo build -p localpaste_cli --bin lpaste --locked | |
| - name: Smoke test (server + CLI CRUD + restart persistence) | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" | |
| export PORT | |
| export DB_PATH="${GITHUB_WORKSPACE}/.tmp/release-smoke-${GITHUB_RUN_ID}" | |
| export RUST_LOG=info | |
| export LP_SERVER="http://127.0.0.1:${PORT}" | |
| mkdir -p "${DB_PATH}" | |
| wait_for_localpaste_ready() { | |
| local max_attempts=120 | |
| local attempt=1 | |
| local health_url="${LP_SERVER}/api/pastes/meta?limit=1" | |
| while (( attempt <= max_attempts )); do | |
| if curl -fsS --connect-timeout 1 --max-time 2 "${health_url}" >/dev/null 2>&1; then | |
| return 0 | |
| fi | |
| if [[ -n "${SERVER_PID:-}" ]] && ! kill -0 "${SERVER_PID}" 2>/dev/null; then | |
| echo "localpaste exited before readiness probe succeeded" >&2 | |
| return 1 | |
| fi | |
| sleep 1 | |
| attempt=$(( attempt + 1 )) | |
| done | |
| echo "localpaste did not become ready after ${max_attempts}s" >&2 | |
| return 1 | |
| } | |
| start_server() { | |
| ./target/debug/localpaste & | |
| SERVER_PID=$! | |
| wait_for_localpaste_ready | |
| } | |
| stop_server() { | |
| if [[ -n "${SERVER_PID:-}" ]]; then | |
| kill "${SERVER_PID}" 2>/dev/null || true | |
| wait "${SERVER_PID}" 2>/dev/null || true | |
| unset SERVER_PID | |
| fi | |
| } | |
| cleanup() { | |
| stop_server | |
| rm -rf "${DB_PATH}" | |
| } | |
| trap cleanup EXIT | |
| start_server | |
| echo "hello from release smoke" | ./target/debug/lpaste --timing new --name "release-smoke" | |
| ./target/debug/lpaste --timing list --limit 5 | |
| ./target/debug/lpaste --timing search "release-smoke" | |
| ID="$(./target/debug/lpaste list --limit 1 | awk '{print $1}')" | |
| ./target/debug/lpaste --timing get "${ID}" | |
| ./target/debug/lpaste --timing delete "${ID}" | |
| echo "persist me" | ./target/debug/lpaste --timing new --name "release-persist" | |
| PERSIST_ID="$(./target/debug/lpaste list --limit 1 | awk '{print $1}')" | |
| stop_server | |
| start_server | |
| ./target/debug/lpaste --timing get "${PERSIST_ID}" | |
| build_package: | |
| needs: | |
| - resolve_tag | |
| - smoke | |
| timeout-minutes: 60 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # Pin Windows runner for stability ("windows-latest" can jump major OS versions). | |
| - os: windows-2022 | |
| target: x86_64-pc-windows-msvc | |
| asset_suffix: windows-x86_64 | |
| packager_config: packaging/windows/packager.json | |
| - os: ubuntu-22.04 | |
| target: x86_64-unknown-linux-gnu | |
| asset_suffix: linux-x86_64 | |
| packager_config: packaging/linux/packager.json | |
| - os: macos-14 | |
| target: aarch64-apple-darwin | |
| asset_suffix: macos-aarch64 | |
| packager_config: packaging/macos/packager.json | |
| runs-on: ${{ matrix.os }} | |
| env: | |
| RESOLVED_TAG: ${{ needs.resolve_tag.outputs.resolved_tag }} | |
| SOURCE_REF: ${{ needs.resolve_tag.outputs.source_ref }} | |
| SOURCE_MODE: ${{ needs.resolve_tag.outputs.source_mode }} | |
| RUST_BACKTRACE: "1" | |
| steps: | |
| - name: Checkout build source | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ env.SOURCE_REF }} | |
| - name: Summarize build source | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| { | |
| echo "### build source" | |
| echo | |
| echo "source_mode=${SOURCE_MODE}" | |
| echo "source_ref=${SOURCE_REF}" | |
| echo "resolved_tag=${RESOLVED_TAG}" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Set up Python | |
| uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 | |
| with: | |
| python-version: "3.11" | |
| - name: Determine macOS signing/notarization availability | |
| if: runner.os == 'macOS' | |
| id: mac_signing | |
| shell: bash | |
| env: | |
| APPLE_SIGNING_CERT_BASE64: ${{ secrets.APPLE_SIGNING_CERT_BASE64 }} | |
| APPLE_SIGNING_CERT_PASSWORD: ${{ secrets.APPLE_SIGNING_CERT_PASSWORD }} | |
| APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| set -euo pipefail | |
| missing=() | |
| required=( | |
| APPLE_SIGNING_CERT_BASE64 | |
| APPLE_SIGNING_CERT_PASSWORD | |
| APPLE_SIGNING_IDENTITY | |
| APPLE_ID | |
| APPLE_APP_SPECIFIC_PASSWORD | |
| APPLE_TEAM_ID | |
| ) | |
| for key in "${required[@]}"; do | |
| if [[ -z "${!key:-}" ]]; then | |
| missing+=("${key}") | |
| fi | |
| done | |
| if [[ "${#missing[@]}" -gt 0 ]]; then | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| echo "missing=$(printf '%s ' "${missing[@]}" | sed 's/[[:space:]]*$//')" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "### macOS signing/notarization skipped" | |
| echo | |
| echo "Missing required secrets: $(printf '%s, ' "${missing[@]}" | sed 's/, $//')" | |
| if [[ "${SOURCE_MODE}" == "release_tag" ]]; then | |
| echo "Permissive release mode: macOS artifacts will be published unsigned/unnotarized." | |
| else | |
| echo "Verification mode: unsigned macOS packaging build will continue." | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| echo "missing=" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master @ 2026-02-23 | |
| with: | |
| toolchain: 1.89.0 | |
| targets: ${{ matrix.target }} | |
| - name: Cache Rust build artifacts | |
| uses: swatinem/rust-cache@aa7c1c80a07a27a84c0aa76d0cef0aad3830e330 # v2.7.8 | |
| with: | |
| key: release-gui-${{ matrix.target }} | |
| - name: Install Linux build dependencies | |
| if: runner.os == 'Linux' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| libgtk-3-dev \ | |
| libayatana-appindicator3-dev \ | |
| librsvg2-dev \ | |
| libxcb-render0-dev \ | |
| libxcb-shape0-dev \ | |
| libxcb-xfixes0-dev \ | |
| patchelf \ | |
| libfuse2 | |
| - name: Ensure WiX Toolset (major 3) | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| function Find-WixBin { | |
| $candidates = New-Object System.Collections.Generic.List[string] | |
| if ($env:WIX) { | |
| $candidates.Add((Join-Path $env:WIX 'bin')) | |
| } | |
| $programFilesX86 = [Environment]::GetEnvironmentVariable('ProgramFiles(x86)') | |
| $programFiles = $env:ProgramFiles | |
| foreach ($root in @($programFilesX86, $programFiles) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique) { | |
| Get-ChildItem -Path $root -Directory -ErrorAction SilentlyContinue | | |
| Where-Object { $_.Name.ToLower().StartsWith('wix') } | | |
| ForEach-Object { $candidates.Add((Join-Path $_.FullName 'bin')) } | |
| } | |
| $candle = Get-Command candle.exe -ErrorAction SilentlyContinue | |
| if ($candle) { | |
| $candidates.Add((Split-Path -Parent $candle.Source)) | |
| } | |
| $light = Get-Command light.exe -ErrorAction SilentlyContinue | |
| if ($light) { | |
| $candidates.Add((Split-Path -Parent $light.Source)) | |
| } | |
| foreach ($dir in ($candidates | Select-Object -Unique)) { | |
| $candlePath = Join-Path $dir 'candle.exe' | |
| $lightPath = Join-Path $dir 'light.exe' | |
| if ((Test-Path $candlePath) -and (Test-Path $lightPath)) { | |
| return $dir | |
| } | |
| } | |
| return $null | |
| } | |
| function Get-WixVersion([string]$wixBin) { | |
| $candle = Join-Path $wixBin 'candle.exe' | |
| if (!(Test-Path $candle)) { | |
| return $null | |
| } | |
| $out = & $candle '-?' 2>&1 | Out-String | |
| if ($out -match 'version\s+([0-9]+(\.[0-9]+){1,3})') { | |
| return $Matches[1] | |
| } | |
| return $null | |
| } | |
| $wixBin = Find-WixBin | |
| if ($wixBin) { | |
| $ver = Get-WixVersion $wixBin | |
| if ($ver) { | |
| $major = [int]($ver.Split('.')[0]) | |
| Write-Host "Found WiX: $wixBin (version=$ver, major=$major)" | |
| if ($major -eq 3) { | |
| exit 0 | |
| } | |
| Write-Host "WiX major $major is not supported by cargo-packager; installing WiX 3.14.1." | |
| } else { | |
| Write-Host "Found WiX bin at $wixBin but failed to parse version; installing WiX 3.14.1." | |
| } | |
| } else { | |
| Write-Host "WiX not found; installing WiX 3.14.1." | |
| } | |
| # NOTE: --allow-downgrade avoids failures when runners ship a newer revision | |
| # of the same WiX major/minor (e.g. 3.14.1.2025xxxx). | |
| choco install wixtoolset --version=3.14.1 -y --no-progress --allow-downgrade --force | |
| - name: Install cargo-packager | |
| run: cargo install cargo-packager --version 0.11.8 --locked | |
| - name: Build GUI release binary | |
| run: cargo build -p localpaste_gui --bin localpaste-gui --release --target ${{ matrix.target }} --locked | |
| - name: Generate macOS icns icon | |
| if: runner.os == 'macOS' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| ICON_SRC="assets/icons/desktop_icon.png" | |
| ICONSET_DIR="dist/macos-icon.iconset" | |
| ICON_DST="assets/icons/desktop_icon.icns" | |
| if [[ ! -f "${ICON_SRC}" ]]; then | |
| echo "missing icon source: ${ICON_SRC}" >&2 | |
| exit 1 | |
| fi | |
| rm -rf "${ICONSET_DIR}" | |
| mkdir -p "${ICONSET_DIR}" | |
| sips -z 16 16 "${ICON_SRC}" --out "${ICONSET_DIR}/icon_16x16.png" >/dev/null | |
| sips -z 32 32 "${ICON_SRC}" --out "${ICONSET_DIR}/icon_16x16@2x.png" >/dev/null | |
| sips -z 32 32 "${ICON_SRC}" --out "${ICONSET_DIR}/icon_32x32.png" >/dev/null | |
| sips -z 64 64 "${ICON_SRC}" --out "${ICONSET_DIR}/icon_32x32@2x.png" >/dev/null | |
| sips -z 128 128 "${ICON_SRC}" --out "${ICONSET_DIR}/icon_128x128.png" >/dev/null | |
| sips -z 256 256 "${ICON_SRC}" --out "${ICONSET_DIR}/icon_128x128@2x.png" >/dev/null | |
| sips -z 256 256 "${ICON_SRC}" --out "${ICONSET_DIR}/icon_256x256.png" >/dev/null | |
| sips -z 512 512 "${ICON_SRC}" --out "${ICONSET_DIR}/icon_256x256@2x.png" >/dev/null | |
| sips -z 512 512 "${ICON_SRC}" --out "${ICONSET_DIR}/icon_512x512.png" >/dev/null | |
| sips -z 1024 1024 "${ICON_SRC}" --out "${ICONSET_DIR}/icon_512x512@2x.png" >/dev/null | |
| iconutil -c icns "${ICONSET_DIR}" -o "${ICON_DST}" | |
| if [[ ! -s "${ICON_DST}" ]]; then | |
| echo "failed to generate icns icon: ${ICON_DST}" >&2 | |
| exit 1 | |
| fi | |
| - name: Prepare packager config and staged runtime | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| prepare_args=( | |
| --tag "${{ env.RESOLVED_TAG }}" | |
| --target "${{ matrix.target }}" | |
| --asset-suffix "${{ matrix.asset_suffix }}" | |
| --packager-config "${{ matrix.packager_config }}" | |
| --runner-os "${{ runner.os }}" | |
| ) | |
| if python .github/scripts/release_gui_prepare.py --help 2>&1 | grep -q -- "--expected-wix-major"; then | |
| prepare_args+=(--expected-wix-major 3) | |
| else | |
| echo "release_gui_prepare.py does not support --expected-wix-major; skipping explicit WiX major assertion" | |
| fi | |
| python .github/scripts/release_gui_prepare.py "${prepare_args[@]}" | |
| - name: Build installer packages | |
| run: python -c "import os, subprocess; subprocess.run(['cargo', 'packager', '--config', os.environ['PACKAGER_CONFIG_PATH']], check=True)" | |
| - name: List packager outputs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| echo "### packager outputs (${{ runner.os }} ${{ matrix.asset_suffix }})" >> "$GITHUB_STEP_SUMMARY" | |
| echo >> "$GITHUB_STEP_SUMMARY" | |
| if [[ -d "dist/packager/${{ matrix.asset_suffix }}" ]]; then | |
| find "dist/packager/${{ matrix.asset_suffix }}" -maxdepth 3 -type f -print | sed 's/^/- /' >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "(missing dist/packager/${{ matrix.asset_suffix }})" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Verify Windows MSI extraction and payload | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = "Stop" | |
| $packagerDir = "dist/packager/${{ matrix.asset_suffix }}" | |
| $msi = Get-ChildItem -Path $packagerDir -Recurse -Filter *.msi | Select-Object -First 1 | |
| if (-not $msi) { | |
| throw "failed to find .msi under $packagerDir" | |
| } | |
| if ($msi.Length -le 0) { | |
| throw "MSI is empty: $($msi.FullName)" | |
| } | |
| $extractDir = Join-Path $env:RUNNER_TEMP "localpaste-msi-extract" | |
| if (Test-Path $extractDir) { | |
| Remove-Item -Recurse -Force $extractDir | |
| } | |
| New-Item -ItemType Directory -Path $extractDir | Out-Null | |
| $args = @("/a", $msi.FullName, "/qn", "TARGETDIR=$extractDir") | |
| $proc = Start-Process -FilePath msiexec.exe -ArgumentList $args -Wait -PassThru -NoNewWindow | |
| if ($proc.ExitCode -ne 0) { | |
| throw "msiexec extraction failed with exit code $($proc.ExitCode)" | |
| } | |
| $payload = Get-ChildItem -Path $extractDir -Recurse -Filter localpaste.exe | Select-Object -First 1 | |
| if (-not $payload) { | |
| throw "failed to find localpaste.exe in extracted MSI payload" | |
| } | |
| if ($payload.Length -le 0) { | |
| throw "extracted localpaste.exe is empty: $($payload.FullName)" | |
| } | |
| - name: Verify Linux AppImage runtime and payload | |
| if: runner.os == 'Linux' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| PACKAGER_DIR="dist/packager/${{ matrix.asset_suffix }}" | |
| APPIMAGE_PATH="$(find "${PACKAGER_DIR}" -maxdepth 4 -type f -iname '*.appimage' | head -n 1)" | |
| if [[ -z "${APPIMAGE_PATH}" ]]; then | |
| echo "failed to find .AppImage under ${PACKAGER_DIR}" >&2 | |
| exit 1 | |
| fi | |
| if [[ ! -s "${APPIMAGE_PATH}" ]]; then | |
| echo "AppImage is empty: ${APPIMAGE_PATH}" >&2 | |
| exit 1 | |
| fi | |
| chmod +x "${APPIMAGE_PATH}" | |
| # AppImage prints its runtime version on stderr on some runners. | |
| if ! "${APPIMAGE_PATH}" --appimage-version > /tmp/localpaste-appimage-version.txt 2>&1; then | |
| echo "AppImage --appimage-version failed" >&2 | |
| cat /tmp/localpaste-appimage-version.txt >&2 || true | |
| exit 1 | |
| fi | |
| if [[ ! -s /tmp/localpaste-appimage-version.txt ]]; then | |
| echo "AppImage runtime did not report a version string" >&2 | |
| exit 1 | |
| fi | |
| cat /tmp/localpaste-appimage-version.txt | |
| # Extract payload and validate expected entry points. | |
| rm -rf squashfs-root | |
| "${APPIMAGE_PATH}" --appimage-extract >/dev/null | |
| if [[ ! -x squashfs-root/usr/bin/localpaste ]]; then | |
| echo "missing expected binary in extracted AppImage: squashfs-root/usr/bin/localpaste" >&2 | |
| find squashfs-root -maxdepth 3 -type f -print >&2 || true | |
| exit 1 | |
| fi | |
| if ! find squashfs-root -type f -name '*.desktop' | grep -q .; then | |
| echo "missing .desktop file in extracted AppImage" >&2 | |
| find squashfs-root -maxdepth 3 -type f -print >&2 || true | |
| exit 1 | |
| fi | |
| - name: Sign, notarize, and verify macOS artifacts | |
| if: runner.os == 'macOS' && steps.mac_signing.outputs.enabled == 'true' | |
| shell: bash | |
| env: | |
| APPLE_SIGNING_CERT_BASE64: ${{ secrets.APPLE_SIGNING_CERT_BASE64 }} | |
| APPLE_SIGNING_CERT_PASSWORD: ${{ secrets.APPLE_SIGNING_CERT_PASSWORD }} | |
| APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| set -euo pipefail | |
| KEYCHAIN_PATH="${RUNNER_TEMP}/localpaste-signing.keychain-db" | |
| KEYCHAIN_PASSWORD="$(openssl rand -hex 16)" | |
| CERT_PATH="${RUNNER_TEMP}/localpaste-signing.p12" | |
| PACKAGER_DIR="dist/packager/${{ matrix.asset_suffix }}" | |
| echo "${APPLE_SIGNING_CERT_BASE64}" | base64 --decode > "${CERT_PATH}" | |
| security create-keychain -p "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" | |
| security set-keychain-settings -lut 21600 "${KEYCHAIN_PATH}" | |
| security unlock-keychain -p "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" | |
| security list-keychains -d user -s "${KEYCHAIN_PATH}" | |
| security import "${CERT_PATH}" -k "${KEYCHAIN_PATH}" -P "${APPLE_SIGNING_CERT_PASSWORD}" -T /usr/bin/codesign -T /usr/bin/security | |
| security set-key-partition-list -S apple-tool:,apple: -s -k "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" | |
| APP_BUNDLE="$(find "${PACKAGER_DIR}" -maxdepth 4 -type d -name '*.app' | head -n 1)" | |
| DMG_PATH="$(find "${PACKAGER_DIR}" -maxdepth 4 -type f -name '*.dmg' | head -n 1)" | |
| if [[ -z "${APP_BUNDLE}" || -z "${DMG_PATH}" ]]; then | |
| echo "failed to find .app and .dmg under ${PACKAGER_DIR}" >&2 | |
| exit 1 | |
| fi | |
| codesign --force --deep --options runtime --timestamp --sign "${APPLE_SIGNING_IDENTITY}" "${APP_BUNDLE}" | |
| codesign --force --timestamp --sign "${APPLE_SIGNING_IDENTITY}" "${DMG_PATH}" | |
| xcrun notarytool submit "${DMG_PATH}" \ | |
| --apple-id "${APPLE_ID}" \ | |
| --password "${APPLE_APP_SPECIFIC_PASSWORD}" \ | |
| --team-id "${APPLE_TEAM_ID}" \ | |
| --wait | |
| xcrun stapler staple "${APP_BUNDLE}" | |
| xcrun stapler staple "${DMG_PATH}" | |
| spctl --assess --type open --verbose=4 "${DMG_PATH}" | |
| - name: Verify signed app bundle inside DMG | |
| if: runner.os == 'macOS' && steps.mac_signing.outputs.enabled == 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| PACKAGER_DIR="dist/packager/${{ matrix.asset_suffix }}" | |
| DMG_PATH="$(find "${PACKAGER_DIR}" -maxdepth 4 -type f -name '*.dmg' | head -n 1)" | |
| if [[ -z "${DMG_PATH}" ]]; then | |
| echo "failed to find .dmg under ${PACKAGER_DIR}" >&2 | |
| exit 1 | |
| fi | |
| MOUNT_POINT="$(mktemp -d)" | |
| cleanup() { | |
| hdiutil detach "${MOUNT_POINT}" >/dev/null 2>&1 || true | |
| rm -rf "${MOUNT_POINT}" | |
| } | |
| trap cleanup EXIT | |
| hdiutil attach "${DMG_PATH}" -mountpoint "${MOUNT_POINT}" -nobrowse -quiet | |
| MOUNTED_APP="$(find "${MOUNT_POINT}" -maxdepth 3 -type d -name '*.app' | head -n 1)" | |
| if [[ -z "${MOUNTED_APP}" ]]; then | |
| echo "failed to find .app inside mounted DMG ${DMG_PATH}" >&2 | |
| exit 1 | |
| fi | |
| codesign --verify --deep --strict --verbose=2 "${MOUNTED_APP}" | |
| spctl --assess --type execute --verbose=4 "${MOUNTED_APP}" | |
| - name: Verify macOS DMG usability | |
| if: runner.os == 'macOS' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| PACKAGER_DIR="dist/packager/${{ matrix.asset_suffix }}" | |
| DMG_PATH="$(find "${PACKAGER_DIR}" -maxdepth 4 -type f -name '*.dmg' | head -n 1)" | |
| if [[ -z "${DMG_PATH}" ]]; then | |
| echo "failed to find .dmg under ${PACKAGER_DIR}" >&2 | |
| exit 1 | |
| fi | |
| hdiutil verify "${DMG_PATH}" | |
| hdiutil imageinfo "${DMG_PATH}" > /tmp/localpaste-dmg-imageinfo.txt | |
| if ! grep -Eq "Format:\\s+UD" /tmp/localpaste-dmg-imageinfo.txt; then | |
| echo "unexpected DMG format reported by hdiutil imageinfo" >&2 | |
| cat /tmp/localpaste-dmg-imageinfo.txt >&2 | |
| exit 1 | |
| fi | |
| - name: Collect release assets | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| python .github/scripts/release_gui_collect.py \ | |
| --tag "${{ env.RESOLVED_TAG }}" \ | |
| --asset-suffix "${{ matrix.asset_suffix }}" \ | |
| --runner-os "${{ runner.os }}" | |
| - name: Upload release assets | |
| uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 | |
| with: | |
| name: release-assets-${{ matrix.asset_suffix }} | |
| path: dist/release/${{ matrix.asset_suffix }}/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| publish: | |
| if: needs.resolve_tag.outputs.source_mode != 'current_ref' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true') | |
| needs: | |
| - resolve_tag | |
| - build_package | |
| runs-on: ubuntu-22.04 | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Download packaged assets | |
| uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 | |
| with: | |
| pattern: release-assets-* | |
| path: dist/release-assets | |
| merge-multiple: true | |
| - name: Generate checksums | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cd dist/release-assets | |
| sha256sum localpaste-* > checksums.sha256 | |
| - name: Detect macOS DMG asset | |
| id: mac_dmg | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if compgen -G "dist/release-assets/localpaste-*.dmg" > /dev/null; then | |
| echo "found=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "found=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Publish release assets | |
| id: publish_release | |
| # Keep v2+ here: `overwrite_files` is not supported by older major tags. | |
| uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 | |
| with: | |
| tag_name: ${{ needs.resolve_tag.outputs.resolved_tag }} | |
| overwrite_files: true | |
| fail_on_unmatched_files: true | |
| files: | | |
| dist/release-assets/localpaste-* | |
| dist/release-assets/checksums.sha256 | |
| - name: Append macOS Gatekeeper note | |
| if: steps.mac_dmg.outputs.found == 'true' | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ needs.resolve_tag.outputs.resolved_tag }} | |
| run: | | |
| set -euo pipefail | |
| NOTE_PREFIX='macOS note:' | |
| NOTE_P1='macOS note: this release may include unsigned/unnotarized LocalPaste macOS artifacts.' | |
| NOTE_P2='If Gatekeeper blocks LocalPaste, use Open Anyway in System Settings > Privacy & Security' | |
| NOTE_P3='or run `xattr -cr /Applications/LocalPaste.app`.' | |
| NOTE="${NOTE_P1} ${NOTE_P2} ${NOTE_P3}" | |
| CURRENT_BODY="$(gh release view "${RELEASE_TAG}" --json body --jq .body)" | |
| if grep -Fq "${NOTE_PREFIX}" <<< "${CURRENT_BODY}"; then | |
| echo "macOS Gatekeeper note already present in release body" | |
| exit 0 | |
| fi | |
| BODY_PATH="$(mktemp)" | |
| if [[ -n "${CURRENT_BODY}" ]]; then | |
| printf "%s\n\n%s\n" "${CURRENT_BODY}" "${NOTE}" > "${BODY_PATH}" | |
| else | |
| printf "%s\n" "${NOTE}" > "${BODY_PATH}" | |
| fi | |
| gh release edit "${RELEASE_TAG}" --notes-file "${BODY_PATH}" |