Skip to content

release-gui

release-gui #23

Workflow file for this run

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}"