Skip to content

build(deps): bump idna from 3.11 to 3.15 #33

build(deps): bump idna from 3.11 to 3.15

build(deps): bump idna from 3.11 to 3.15 #33

name: Release Validate
on:
pull_request:
branches: ["**"]
paths:
- '.github/workflows/release.yml'
- '.github/workflows/release-validate.yml'
- 'app.spec'
- 'requirements*.txt'
- 'requirements*.in'
- 'src/services/update_*.py'
- 'src/utils/install_context.py'
- 'src/main.py'
- 'src/cli.py'
- 'src/resources/icons/app-icon.png'
push:
branches:
- main
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
APP_NAME: HistorySync
APP_ID: historysync # lowercase, used for .deb / AppImage / desktop entry
PYTHON_STANDALONE_VERSION: '3.12.12'
PYTHON_STANDALONE_RELEASE_TAG: '20251202'
PYTHON_BUILD_VERSION: '3.12'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# Extracts a clean tag (vX.Y.Z) and bare number (X.Y.Z) from the git ref and
# exposes them as outputs for every downstream job.
# Also emits a matrix JSON: PR events run only the default-validation platforms;
# push/dispatch runs the full matrix.
setup:
name: Parse Version
runs-on: ubuntu-latest
outputs:
version_tag: ${{ steps.ver.outputs.tag }}
version_num: ${{ steps.ver.outputs.num }}
matrix: ${{ steps.matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- name: Resolve version tag and bare number
id: ver
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
TAG="$GITHUB_REF_NAME"
else
# pull_request / branch push / workflow_dispatch: synthesise a dev version
TAG="v0.0.0-dev.$(git rev-parse --short HEAD)"
fi
NUM="${TAG#v}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "num=${NUM}" >> "$GITHUB_OUTPUT"
echo "::notice title=Version::Building ${TAG} (${NUM})"
- name: Build platform matrix
id: matrix
shell: bash
run: |
FULL='{"include":[{"platform_name":"windows-x64","os_image":"windows-latest","python_arch":"x86_64-pc-windows-msvc","arch_display":"x64","validate_default":true},{"platform_name":"windows-arm64","os_image":"windows-11-arm","python_arch":"aarch64-pc-windows-msvc","arch_display":"arm64","validate_default":false},{"platform_name":"linux-x64","os_image":"ubuntu-22.04","python_arch":"x86_64-unknown-linux-gnu","arch_display":"x86_64","deb_arch":"amd64","rpm_arch":"x86_64","validate_default":true},{"platform_name":"linux-arm64","os_image":"ubuntu-24.04-arm","python_arch":"aarch64-unknown-linux-gnu","arch_display":"arm64","deb_arch":"arm64","rpm_arch":"aarch64","validate_default":false},{"platform_name":"macos-arm64","os_image":"macos-15","python_arch":"aarch64-apple-darwin","arch_display":"arm64","validate_default":true},{"platform_name":"macos-x64","os_image":"macos-15-intel","python_arch":"x86_64-apple-darwin","arch_display":"x64","validate_default":false}]}'
PR_ONLY='{"include":[{"platform_name":"windows-x64","os_image":"windows-latest","python_arch":"x86_64-pc-windows-msvc","arch_display":"x64","validate_default":true},{"platform_name":"linux-x64","os_image":"ubuntu-22.04","python_arch":"x86_64-unknown-linux-gnu","arch_display":"x86_64","deb_arch":"amd64","rpm_arch":"x86_64","validate_default":true},{"platform_name":"macos-arm64","os_image":"macos-15","python_arch":"aarch64-apple-darwin","arch_display":"arm64","validate_default":true}]}'
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "matrix=${PR_ONLY}" >> "$GITHUB_OUTPUT"
else
echo "matrix=${FULL}" >> "$GITHUB_OUTPUT"
fi
# All build jobs depend on this passing; a red test suite blocks the release.
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_BUILD_VERSION }}
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-test.in
# Run with the same flags as test.yml so the release gate has identical
# rigour to the CI gate. Diverging flags risk a test passing here but
# failing (or hiding failures) in the regular workflow.
- name: Run pytest with coverage
env:
QT_QPA_PLATFORM: offscreen
run: |
pytest \
--cov=src \
--cov-report=term-missing \
--cov-report=xml:coverage.xml \
-q --tb=short
# Matrix (PR): windows-x64, linux-x64, macos-arm64 (validate_default platforms only)
# Matrix (push/dispatch): all six platforms
# The setup job emits the correct JSON for each event type.
build:
name: Build · ${{ matrix.platform_name }}
needs: [setup, test]
runs-on: ${{ matrix.os_image }}
env:
VERSION_TAG: ${{ needs.setup.outputs.version_tag }}
VERSION_NUM: ${{ needs.setup.outputs.version_num }}
HAS_WIN_CERT: ${{ secrets.WINDOWS_CERTIFICATE != '' }}
HAS_APPLE_CERT: ${{ secrets.APPLE_CERTIFICATE != '' }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_BUILD_VERSION }}
cache: pip
- name: Install build dependencies (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
python -m pip install --upgrade pip
pip install pyinstaller
if (Test-Path requirements.in) { pip install -r requirements.in }
- name: Install build dependencies (Unix)
if: runner.os != 'Windows'
run: |
python -m pip install --upgrade pip
pip install pyinstaller
if [ -f requirements.in ]; then pip install -r requirements.in; fi
# On Linux / macOS PyInstaller bundles a complete runtime on its own.
# On Windows we replace the bundled vcruntime + Python DLLs with the
# known-good indygreg standalone build to avoid MSVC dependency issues.
- name: Restore standalone Python runtime cache (Windows)
if: runner.os == 'Windows'
id: cache-python-runtime
uses: actions/cache/restore@v4
with:
path: python_minimal
key: python-standalone-${{ env.PYTHON_STANDALONE_VERSION }}-${{ env.PYTHON_STANDALONE_RELEASE_TAG }}-${{ matrix.python_arch }}
- name: Download standalone Python runtime (Windows)
if: runner.os == 'Windows' && steps.cache-python-runtime.outputs.cache-hit != 'true'
shell: bash
run: |
BASE="https://github.com/indygreg/python-build-standalone/releases/download"
TAG="${{ env.PYTHON_STANDALONE_RELEASE_TAG }}"
VER="${{ env.PYTHON_STANDALONE_VERSION }}"
ARCH="${{ matrix.python_arch }}"
URL="${BASE}/${TAG}/cpython-${VER}+${TAG}-${ARCH}-install_only_stripped.tar.gz"
echo "Fetching: ${URL}"
curl -fsSL "$URL" -o runtime.tar.gz
tar -xzf runtime.tar.gz && mv python python_minimal
- name: Save standalone Python runtime cache (Windows)
if: runner.os == 'Windows' && steps.cache-python-runtime.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: python_minimal
key: python-standalone-${{ env.PYTHON_STANDALONE_VERSION }}-${{ env.PYTHON_STANDALONE_RELEASE_TAG }}-${{ matrix.python_arch }}
# app.spec produces two bundles in one pass:
# dist/HistorySync/ -full GUI bundle (for all installers)
# dist/hsync/ -standalone CLI bundle (for headless tar.gz)
# After the build we copy the CLI binary into the GUI bundle so every
# installer ships both HistorySync (GUI) and hsync (CLI).
- name: Build with PyInstaller
shell: bash
run: pyinstaller app.spec --noconfirm
# All downstream packaging steps operate on dist/HistorySync/ only.
# Copying hsync[.exe] there means installers get both binaries for free.
- name: Embed CLI binary into GUI bundle (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
Copy-Item "dist\hsync\hsync.exe" "dist\${{ env.APP_NAME }}\hsync.exe" -Force
Write-Host "Embedded: hsync.exe ->dist\${{ env.APP_NAME }}\hsync.exe"
- name: Embed CLI binary into GUI bundle (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
cp "dist/hsync/hsync" "dist/${{ env.APP_NAME }}/hsync"
chmod +x "dist/${{ env.APP_NAME }}/hsync"
echo "Embedded: hsync ->dist/${{ env.APP_NAME }}/hsync"
- name: Embed CLI binary into GUI bundle (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
cp "dist/hsync/hsync" "dist/${{ env.APP_NAME }}.app/Contents/MacOS/hsync"
chmod +x "dist/${{ env.APP_NAME }}.app/Contents/MacOS/hsync"
echo "Embedded: hsync ->dist/${{ env.APP_NAME }}.app/Contents/MacOS/hsync"
# Covers both the GUI bundle (dist/HistorySync/) and the standalone
# CLI bundle (dist/hsync/) so neither leaks .po files into a release.
- name: Strip .po / .pot files (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
foreach ($dir in @("dist\${{ env.APP_NAME }}", "dist\hsync")) {
if (Test-Path $dir) {
Get-ChildItem -Path $dir -Recurse -Include "*.po","*.pot" | Remove-Item -Force
if (Get-ChildItem -Path $dir -Recurse -Include "*.po","*.pot") {
Write-Error "Error: residual .po/.pot files remain in $dir"
}
}
}
- name: Strip .po / .pot files (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
for dir in "dist/${{ env.APP_NAME }}" "dist/hsync"; do
[ -d "$dir" ] || continue
find "$dir" -type f \( -name "*.po" -o -name "*.pot" \) -delete
if find "$dir" -type f \( -name "*.po" -o -name "*.pot" \) | grep -q .; then
echo "Error: residual .po/.pot files remain in $dir" && exit 1
fi
done
# These steps are no-ops until the corresponding secrets are populated.
#
# Windows: add repository secrets
# WINDOWS_CERTIFICATE -base64-encoded PFX file
# WINDOWS_CERTIFICATE_PASSWORD -PFX passphrase
#
# macOS: add repository secrets
# APPLE_CERTIFICATE -base64-encoded .p12 Developer ID cert
# APPLE_CERTIFICATE_PASSWORD -.p12 passphrase
# APPLE_ID -Apple ID email used for notarization
# APPLE_ID_PASSWORD -app-specific password (not your Apple ID password)
# APPLE_TEAM_ID -10-character Apple Team ID
# IMPORTANT: this step must run BEFORE code signing so that the final
# DLLs (python312.dll, vcruntime140.dll, etc.) are the ones that get
# signed. Signing first and then overwriting the DLLs would invalidate
# the signature on those files.
# Swap out the Python interpreter and vcruntime DLLs that PyInstaller
# pulled from the build machine with the clean standalone build we
# downloaded earlier -ensures a consistent, minimal runtime footprint.
- name: Replace Python runtime (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$src = "python_minimal"
$dlls = @("python.exe","pythonw.exe","python3.dll","python312.dll",
"vcruntime140.dll","vcruntime140_1.dll")
# Replace in both the GUI bundle and the standalone CLI bundle
foreach ($dist in @("dist\${{ env.APP_NAME }}", "dist\hsync")) {
if (-not (Test-Path $dist)) { continue }
foreach ($f in $dlls) {
$s = Join-Path $src $f
if (Test-Path $s) {
Copy-Item $s $dist -Force
Write-Host "Replaced in ${dist}: $f"
}
}
}
- name: Sign executable (Windows)
if: runner.os == 'Windows' && env.HAS_WIN_CERT == 'true'
shell: pwsh
env:
CERT_B64: ${{ secrets.WINDOWS_CERTIFICATE }}
CERT_PWD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
run: |
$certBytes = [System.Convert]::FromBase64String($env:CERT_B64)
[System.IO.File]::WriteAllBytes("cert.pfx", $certBytes)
$signtoolCandidates = @(
"C:\Program Files (x86)\Windows Kits\10\bin\x64\signtool.exe",
"C:\Program Files (x86)\Windows Kits\10\bin\arm64\signtool.exe",
"C:\Program Files\Windows Kits\10\bin\arm64\signtool.exe",
"C:\Program Files\Windows Kits\10\bin\x64\signtool.exe"
)
$signtool = $null
foreach ($candidate in $signtoolCandidates) {
if (Test-Path $candidate) {
$signtool = $candidate
break
}
}
if (-not $signtool) {
$signtool = (Get-Command signtool.exe -ErrorAction SilentlyContinue)?.Source
}
if (-not $signtool) { throw "signtool.exe not found. Install Windows SDK." }
# Sign both the GUI launcher and the CLI binary
foreach ($target in @(
"dist\${{ env.APP_NAME }}\${{ env.APP_NAME }}.exe",
"dist\${{ env.APP_NAME }}\hsync.exe"
)) {
& $signtool sign /fd sha256 /td sha256 /tr http://timestamp.digicert.com `
/f cert.pfx /p $env:CERT_PWD "$target"
if ($LASTEXITCODE -ne 0) { throw "signtool failed for $target" }
Write-Host "::notice::Signed: $target"
}
Remove-Item cert.pfx -Force
- name: Sign and notarize app bundle (macOS)
if: runner.os == 'macOS' && env.HAS_APPLE_CERT == 'true'
shell: bash
env:
APPLE_CERT: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERT_PWD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PWD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
echo "$APPLE_CERT" | base64 --decode > cert.p12
security create-keychain -p "" build.keychain
security import cert.p12 -k build.keychain -P "$APPLE_CERT_PWD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain
security list-keychains -d user -s build.keychain
# Derive the signing identity CN from the imported certificate.
# APPLE_TEAM_ID is the 10-character Team ID, not the certificate Common
# Name that codesign --sign expects. Using Team ID directly causes
# codesign to either fail or pick the wrong identity.
SIGNING_IDENTITY=$(security find-identity -v -p codesigning build.keychain \
| grep "Developer ID Application" \
| head -1 \
| sed 's/.*"\(.*\)"/\1/')
if [ -z "$SIGNING_IDENTITY" ]; then
echo "::error::No 'Developer ID Application' certificate found in build.keychain."
exit 1
fi
echo "::notice::Signing with identity: ${SIGNING_IDENTITY}"
codesign --deep --force --verify --verbose \
--sign "$SIGNING_IDENTITY" \
--options runtime \
"dist/${{ env.APP_NAME }}.app"
ditto -c -k --keepParent "dist/${{ env.APP_NAME }}.app" _notarize.zip
xcrun notarytool submit _notarize.zip \
--apple-id "$APPLE_ID" --password "$APPLE_ID_PWD" \
--team-id "$APPLE_TEAM_ID" --wait
xcrun stapler staple "dist/${{ env.APP_NAME }}.app"
rm cert.p12 _notarize.zip
echo "::notice::App bundle signed and notarized."
- name: Package portable ZIP (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
# Drop a marker so the app auto-enables portable mode on launch
New-Item -Path "dist\${{ env.APP_NAME }}\.portable" -ItemType File -Force | Out-Null
$out = "${{ env.APP_NAME }}-${{ env.VERSION_TAG }}-windows-${{ matrix.arch_display }}-portable.zip"
Push-Location "dist\${{ env.APP_NAME }}"
Compress-Archive -Path * -DestinationPath "..\..\$out"
Pop-Location
# Remove marker so the installer build uses the default (non-portable) paths
Remove-Item "dist\${{ env.APP_NAME }}\.portable" -Force
Write-Host "Created: $out ($('{0:N2}' -f ((Get-Item $out).length / 1MB)) MB)"
- name: Generate Inno Setup script
if: runner.os == 'Windows'
shell: pwsh
run: |
$appName = "${{ env.APP_NAME }}"
$ver = "${{ env.VERSION_NUM }}"
$repo = "${{ github.repository }}"
$outName = "${appName}-${{ env.VERSION_TAG }}-windows-${{ matrix.arch_display }}-setup"
$archMode = if ("${{ matrix.arch_display }}" -eq "arm64") { "arm64" } else { "x64compatible" }
$archAllowed = if ("${{ matrix.arch_display }}" -eq "arm64") { "arm64" } else { "x64compatible and not arm64" }
@(
"[Setup]",
"AppName=${appName}",
"AppVersion=${ver}",
"AppPublisher=${appName} Project",
"AppPublisherURL=https://github.com/${repo}",
"AppSupportURL=https://github.com/${repo}/issues",
"DefaultDirName={autopf}\${appName}",
"DefaultGroupName=${appName}",
"AllowNoIcons=yes",
"OutputDir=.",
"OutputBaseFilename=${outName}",
"Compression=lzma2/ultra64",
"SolidCompression=yes",
"PrivilegesRequired=lowest",
"ArchitecturesAllowed=${archAllowed}",
"ArchitecturesInstallIn64BitMode=${archMode}",
"WizardStyle=modern",
"DisableWelcomePage=yes",
"LicenseFile=LICENSE",
"UninstallDisplayIcon={app}\${appName}.exe",
"ChangesEnvironment=yes",
"",
"[Languages]",
'Name: "english"; MessagesFile: "compiler:Default.isl"',
"",
"[Tasks]",
'Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked',
'Name: "addtopath"; Description: "Add hsync to the system PATH"; GroupDescription: "Command-line interface:"; Flags: checkedonce',
"",
"[Files]",
"Source: ""dist\${appName}\*""; DestDir: ""{app}""; Flags: ignoreversion recursesubdirs createallsubdirs",
"",
"[Icons]",
"Name: ""{group}\${appName}""; Filename: ""{app}\${appName}.exe""",
"Name: ""{group}\{cm:UninstallProgram,${appName}}""; Filename: ""{uninstallexe}""",
"Name: ""{userdesktop}\${appName}""; Filename: ""{app}\${appName}.exe""; Tasks: desktopicon",
"",
"[Registry]",
"Root: HKCU; Subkey: ""Environment""; ValueType: expandsz; ValueName: ""Path""; ValueData: ""{olddata};{app}""; Tasks: addtopath; Check: NeedsAddPath(ExpandConstant('{app}'))",
"",
"[Run]",
"Filename: ""{app}\${appName}.exe""; Description: ""{cm:LaunchProgram,${appName}}""; Flags: nowait postinstall skipifsilent",
"",
"[Code]",
"function NeedsAddPath(Param: string): boolean;",
"var",
" OrigPath: string;",
"begin",
" if not RegQueryStringValue(HKCU, 'Environment', 'Path', OrigPath) then begin",
" Result := True;",
" exit;",
" end;",
" Result := Pos(Uppercase(Param), Uppercase(OrigPath)) = 0;",
"end;"
) -join "`n" | Set-Content installer.iss -Encoding UTF8
- name: Compile Inno Setup installer
if: runner.os == 'Windows'
shell: pwsh
run: |
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer.iss
if ($LASTEXITCODE -ne 0) { throw "Inno Setup compilation failed." }
$exe = "${{ env.APP_NAME }}-${{ env.VERSION_TAG }}-windows-${{ matrix.arch_display }}-setup.exe"
Write-Host "Created: $exe ($('{0:N2}' -f ((Get-Item $exe).length / 1MB)) MB)"
- name: Package hsync portable ZIP (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$out = "hsync-${{ env.VERSION_TAG }}-windows-${{ matrix.arch_display }}.zip"
Push-Location "dist\hsync"
Compress-Archive -Path * -DestinationPath "..\..\$out"
Pop-Location
Write-Host "Created: $out ($('{0:N2}' -f ((Get-Item $out).length / 1MB)) MB)"
- name: Generate hsync Inno Setup script (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$ver = "${{ env.VERSION_NUM }}"
$repo = "${{ github.repository }}"
$outName = "hsync-${{ env.VERSION_TAG }}-windows-${{ matrix.arch_display }}-setup"
$archMode = if ("${{ matrix.arch_display }}" -eq "arm64") { "arm64" } else { "x64compatible" }
$archAllowed = if ("${{ matrix.arch_display }}" -eq "arm64") { "arm64" } else { "x64compatible and not arm64" }
@(
"[Setup]",
"AppName=hsync",
"AppVersion=${ver}",
"AppPublisher=${{ env.APP_NAME }} Project",
"AppPublisherURL=https://github.com/${repo}",
"AppSupportURL=https://github.com/${repo}/issues",
"DefaultDirName={autopf}\hsync",
"DefaultGroupName=hsync",
"AllowNoIcons=yes",
"OutputDir=.",
"OutputBaseFilename=${outName}",
"Compression=lzma2/ultra64",
"SolidCompression=yes",
"PrivilegesRequired=lowest",
"ArchitecturesAllowed=${archAllowed}",
"ArchitecturesInstallIn64BitMode=${archMode}",
"WizardStyle=modern",
"DisableWelcomePage=yes",
"LicenseFile=LICENSE",
"UninstallDisplayIcon={app}\hsync.exe",
"ChangesEnvironment=yes",
"",
"[Languages]",
'Name: "english"; MessagesFile: "compiler:Default.isl"',
"",
"[Tasks]",
'Name: "addtopath"; Description: "Add hsync to the system PATH"; GroupDescription: "Command-line interface:"; Flags: checkedonce',
"",
"[Files]",
'Source: "dist\hsync\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs',
"",
"[Registry]",
'Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; Tasks: addtopath; Check: NeedsAddPath(ExpandConstant(''{app}''))',
"",
"[Code]",
"function NeedsAddPath(Param: string): boolean;",
"var",
" OrigPath: string;",
"begin",
" if not RegQueryStringValue(HKCU, 'Environment', 'Path', OrigPath) then begin",
" Result := True;",
" exit;",
" end;",
" Result := Pos(Uppercase(Param), Uppercase(OrigPath)) = 0;",
"end;"
) -join "`n" | Set-Content hsync-installer.iss -Encoding UTF8
- name: Compile hsync Inno Setup installer (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" hsync-installer.iss
if ($LASTEXITCODE -ne 0) { throw "Inno Setup compilation failed for hsync." }
$exe = "hsync-${{ env.VERSION_TAG }}-windows-${{ matrix.arch_display }}-setup.exe"
Write-Host "Created: $exe ($('{0:N2}' -f ((Get-Item $exe).length / 1MB)) MB)"
- name: Build standalone CLI tar.gz (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
CLI_OUT="hsync-${{ env.VERSION_TAG }}-macos-${{ matrix.arch_display }}.tar.gz"
tar -czf "$CLI_OUT" -C dist/ hsync
echo "Created: $CLI_OUT ($(du -sh "$CLI_OUT" | cut -f1))"
- name: Package DMG (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
APP_BUNDLE="dist/${{ env.APP_NAME }}.app"
OUT="${{ env.APP_NAME }}-${{ env.VERSION_TAG }}-macos-${{ matrix.arch_display }}.dmg"
# Wait for any open file handles to clear before calling hdiutil
MAX=30; WAITED=0
while lsof +D "$APP_BUNDLE" >/dev/null 2>&1 && [ $WAITED -lt $MAX ]; do
echo "Waiting for file handles (${WAITED}/${MAX}s)..." && sleep 2
WAITED=$((WAITED+2))
done
if [ $WAITED -ge $MAX ]; then
echo "Timed out waiting for file handles:" && lsof +D "$APP_BUNDLE" && exit 1
fi
hdiutil create \
-volname "${{ env.APP_NAME }}" \
-srcfolder "$APP_BUNDLE" \
-ov -format UDZO \
"$OUT"
echo "Created: $OUT"
- name: Install Linux packaging tools
if: runner.os == 'Linux'
run: |
sudo apt-get update -qq
if apt-cache show libfuse2t64 >/dev/null 2>&1; then
FUSE_PKG="libfuse2t64"
else
FUSE_PKG="libfuse2"
fi
sudo apt-get install -y "${FUSE_PKG}" fakeroot dpkg-dev rpm
- name: Build AppImage (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
DIST="dist/${{ env.APP_NAME }}"
APPDIR="$RUNNER_TEMP/AppDir"
OUT="${{ env.APP_NAME }}-${{ env.VERSION_TAG }}-linux-${{ matrix.arch_display }}.AppImage"
case "${{ matrix.arch_display }}" in
x86_64) APPIMAGETOOL_ARCH="x86_64" ;;
arm64) APPIMAGETOOL_ARCH="aarch64" ;;
*) echo "::error::Unknown arch: ${{ matrix.arch_display }}" && exit 1 ;;
esac
# Download appimagetool
curl -fsSL -o "$RUNNER_TEMP/appimagetool" \
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${APPIMAGETOOL_ARCH}.AppImage"
chmod +x "$RUNNER_TEMP/appimagetool"
# All PyInstaller output lives under usr/lib/historysync; the AppRun
# launcher script at the root delegates to the real binary there.
mkdir -p "$APPDIR/usr/lib/${{ env.APP_ID }}"
cp -r "$DIST"/. "$APPDIR/usr/lib/${{ env.APP_ID }}/"
# AppRun -executes the bundled binary; PyInstaller's bootloader
# handles LD_LIBRARY_PATH internally, so no export needed here.
{
echo '#!/bin/bash'
echo 'SELF="$(readlink -f "$0")"'
echo 'HERE="${SELF%/*}"'
echo 'if [[ -z "${QT_QPA_PLATFORM:-}" ]]; then'
echo ' if [[ -n "${DISPLAY:-}" ]]; then'
echo ' export QT_QPA_PLATFORM=xcb'
echo ' elif [[ -n "${WAYLAND_DISPLAY:-}" ]]; then'
echo ' export QT_QPA_PLATFORM=wayland'
echo ' fi'
echo 'fi'
echo 'exec "${HERE}/usr/lib/historysync/HistorySync" "$@"'
} > "$APPDIR/AppRun"
chmod +x "$APPDIR/AppRun"
# Desktop integration
{
echo '[Desktop Entry]'
echo 'Name=HistorySync'
echo 'Exec=HistorySync'
echo 'Icon=historysync'
echo 'Type=Application'
echo 'Categories=Utility;'
} > "$APPDIR/${{ env.APP_ID }}.desktop"
# Icon (256x256 PNG preferred by appimagetool)
if [ -f "src/resources/icons/app-icon.png" ]; then
cp "src/resources/icons/app-icon.png" "$APPDIR/${{ env.APP_ID }}.png"
else
# Minimal transparent 1x1 PNG fallback
echo "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" | base64 -d > "$APPDIR/${{ env.APP_ID }}.png"
fi
# APPIMAGE_EXTRACT_AND_RUN=1: run appimagetool without mounting via FUSE
# (belt-and-suspenders alongside the libfuse2 install above)
ARCH="${APPIMAGETOOL_ARCH}" APPIMAGE_EXTRACT_AND_RUN=1 \
"$RUNNER_TEMP/appimagetool" "$APPDIR" "$OUT"
echo "Created: $OUT"
- name: Build .deb package (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
NUM="${{ env.VERSION_NUM }}"
DIST="dist/${{ env.APP_NAME }}"
case "${{ matrix.arch_display }}" in
x86_64) DEB_ARCH="amd64" ;;
arm64) DEB_ARCH="arm64" ;;
*) echo "::error::Unknown arch: ${{ matrix.arch_display }}" && exit 1 ;;
esac
DEB="${{ env.APP_ID }}_${NUM}_${DEB_ARCH}" # Debian naming convention: lowercase, underscores
# Directory tree expected by dpkg-deb
mkdir -p "$DEB/DEBIAN"
mkdir -p "$DEB/usr/lib/${{ env.APP_ID }}"
mkdir -p "$DEB/usr/bin"
mkdir -p "$DEB/usr/share/applications"
mkdir -p "$DEB/usr/share/icons/hicolor/256x256/apps"
mkdir -p "$DEB/usr/share/doc/${{ env.APP_ID }}"
# Application files
cp -r "$DIST"/. "$DEB/usr/lib/${{ env.APP_ID }}/"
# License (Debian Policy §12.5: must be installed as copyright)
[ -f LICENSE ] && cp LICENSE "$DEB/usr/share/doc/${{ env.APP_ID }}/copyright"
# usr/bin shims: expose both the GUI launcher and the CLI on $PATH
ln -s "/usr/lib/${{ env.APP_ID }}/${{ env.APP_NAME }}" \
"$DEB/usr/bin/${{ env.APP_ID }}"
ln -s "/usr/lib/${{ env.APP_ID }}/hsync" \
"$DEB/usr/bin/hsync"
# Desktop entry
{
echo '[Desktop Entry]'
echo 'Name=${{ env.APP_NAME }}'
echo 'Comment=Browser history synchronization tool'
echo 'Exec=/usr/lib/${{ env.APP_ID }}/${{ env.APP_NAME }}'
echo 'Icon=${{ env.APP_ID }}'
echo 'Type=Application'
echo 'Categories=Utility;'
echo 'StartupNotify=true'
} > "$DEB/usr/share/applications/${{ env.APP_ID }}.desktop"
# Icon
[ -f "src/resources/icons/app-icon.png" ] && \
cp "src/resources/icons/app-icon.png" \
"$DEB/usr/share/icons/hicolor/256x256/apps/${{ env.APP_ID }}.png"
# DEBIAN/control -Installed-Size is in KiB per policy
INSTALLED_KB=$(du -sk "$DEB/usr" | cut -f1)
{
echo 'Package: ${{ env.APP_ID }}'
echo "Version: ${NUM}"
echo "Architecture: ${DEB_ARCH}"
echo 'Maintainer: ${{ env.APP_NAME }} Project <noreply@github.com>'
echo "Installed-Size: ${INSTALLED_KB}"
echo 'Depends: libgl1, libxcb-cursor0, libxcb-icccm4, libxcb-util1, libxcb-image0, libxcb-keysyms1, libxcb-render-util0, libxcb-shape0, libxcb-xkb1, libxkbcommon-x11-0'
echo 'Homepage: https://github.com/${{ github.repository }}'
echo 'Description: Browser History Sync'
echo ' A cross-platform browser history synchronization and search tool.'
echo ' Includes the hsync CLI for headless / scripted use.'
} > "$DEB/DEBIAN/control"
# maintainer scripts: refresh desktop database on install/remove
printf '#!/bin/sh\nupdate-desktop-database /usr/share/applications || true\n' \
> "$DEB/DEBIAN/postinst"
printf '#!/bin/sh\nupdate-desktop-database /usr/share/applications || true\n' \
> "$DEB/DEBIAN/postrm"
chmod 0755 "$DEB/DEBIAN/postinst" "$DEB/DEBIAN/postrm"
fakeroot dpkg-deb --build "$DEB" \
"${{ env.APP_ID }}_${NUM}_${DEB_ARCH}.deb"
echo "Created: ${{ env.APP_ID }}_${NUM}_${DEB_ARCH}.deb"
- name: Build .rpm package (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
NUM="${{ env.VERSION_NUM }}"
DIST="dist/${{ env.APP_NAME }}"
RPM_VERSION="${NUM}"
RPM_RELEASE="1"
if [[ "${NUM}" =~ ^([0-9]+(\.[0-9]+)*)([-+].+)$ ]]; then
RPM_VERSION="${BASH_REMATCH[1]}~${BASH_REMATCH[3]#[-+]}"
fi
RPM_VERSION="${RPM_VERSION//-/.}"
RPM_VERSION="${RPM_VERSION//[^A-Za-z0-9._~+]/.}"
RPM_RELEASE="${RPM_RELEASE//[^A-Za-z0-9._~+]/.}"
case "${{ matrix.arch_display }}" in
x86_64) RPM_ARCH="x86_64" ;;
arm64) RPM_ARCH="aarch64" ;;
*) echo "::error::Unknown arch: ${{ matrix.arch_display }}" && exit 1 ;;
esac
RPMBUILD="$RUNNER_TEMP/rpmbuild"
BUILDROOT="${RPMBUILD}/BUILDROOT/${{ env.APP_ID }}-${NUM}-1.${RPM_ARCH}"
mkdir -p "${RPMBUILD}"/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
mkdir -p "${BUILDROOT}/usr/lib/${{ env.APP_ID }}"
mkdir -p "${BUILDROOT}/usr/bin"
mkdir -p "${BUILDROOT}/usr/share/applications"
mkdir -p "${BUILDROOT}/usr/share/icons/hicolor/256x256/apps"
cp -r "$DIST"/. "${BUILDROOT}/usr/lib/${{ env.APP_ID }}/"
ln -s "/usr/lib/${{ env.APP_ID }}/${{ env.APP_NAME }}" "${BUILDROOT}/usr/bin/${{ env.APP_ID }}"
ln -s "/usr/lib/${{ env.APP_ID }}/hsync" "${BUILDROOT}/usr/bin/hsync"
{
echo '[Desktop Entry]'
echo 'Name=${{ env.APP_NAME }}'
echo 'Comment=Browser history synchronization tool'
echo 'Exec=/usr/lib/${{ env.APP_ID }}/${{ env.APP_NAME }}'
echo 'Icon=${{ env.APP_ID }}'
echo 'Type=Application'
echo 'Categories=Utility;'
echo 'StartupNotify=true'
} > "${BUILDROOT}/usr/share/applications/${{ env.APP_ID }}.desktop"
if [ -f "src/resources/icons/app-icon.png" ]; then
cp "src/resources/icons/app-icon.png" \
"${BUILDROOT}/usr/share/icons/hicolor/256x256/apps/${{ env.APP_ID }}.png"
else
echo "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" \
| base64 -d > "${BUILDROOT}/usr/share/icons/hicolor/256x256/apps/${{ env.APP_ID }}.png"
fi
{
echo "Name: ${{ env.APP_ID }}"
echo "Version: ${RPM_VERSION}"
echo "Release: ${RPM_RELEASE}%{?dist}"
echo "Summary: Browser History Sync"
echo "License: Apache-2.0"
echo "URL: https://github.com/${{ github.repository }}"
echo "BuildArch: ${RPM_ARCH}"
echo
echo "Requires: mesa-libGL"
echo "Requires: xcb-util-cursor"
echo "Requires: xcb-util-wm"
echo "Requires: xcb-util-image"
echo "Requires: xcb-util-keysyms"
echo "Requires: xcb-util-renderutil"
echo "Requires: libxkbcommon-x11"
echo
echo "%description"
echo "A cross-platform browser history synchronization and search tool."
echo "Includes the hsync CLI for headless / scripted use."
echo
echo "%files"
echo "/usr/lib/${{ env.APP_ID }}/"
echo "/usr/bin/${{ env.APP_ID }}"
echo "/usr/bin/hsync"
echo "/usr/share/applications/${{ env.APP_ID }}.desktop"
echo "/usr/share/icons/hicolor/256x256/apps/${{ env.APP_ID }}.png"
echo
echo "%post"
echo "update-desktop-database /usr/share/applications || true"
echo
echo "%postun"
echo "update-desktop-database /usr/share/applications || true"
} > "${RPMBUILD}/SPECS/${{ env.APP_ID }}.spec"
rpmbuild -bb \
--define "_topdir ${RPMBUILD}" \
--define "_buildrootdir ${RPMBUILD}/BUILDROOT" \
--buildroot "${BUILDROOT}" \
--target "${RPM_ARCH}" \
"${RPMBUILD}/SPECS/${{ env.APP_ID }}.spec"
RPM_FILE=$(find "${RPMBUILD}/RPMS" -name "*.rpm" | head -1)
if [ -z "${RPM_FILE}" ]; then
echo "::error::rpmbuild did not produce an RPM file."
exit 1
fi
OUT_RPM="${{ env.APP_ID }}_${NUM}_${RPM_ARCH}.rpm"
cp "$RPM_FILE" "$OUT_RPM"
echo "Created: $OUT_RPM"
- name: Build tar.gz (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
OUT="${{ env.APP_NAME }}-${{ env.VERSION_TAG }}-linux-${{ matrix.arch_display }}.tar.gz"
tar -czf "$OUT" -C dist/ "${{ env.APP_NAME }}"
echo "Created: $OUT"
- name: Build standalone CLI tar.gz (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
CLI_OUT="hsync-${{ env.VERSION_TAG }}-linux-${{ matrix.arch_display }}.tar.gz"
tar -czf "$CLI_OUT" -C dist/ hsync
CLI_SIZE=$(du -sh "$CLI_OUT" | cut -f1)
echo "Created: $CLI_OUT (${CLI_SIZE})"
- name: Upload artifacts (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: pkg-${{ matrix.platform_name }}
path: |
${{ env.APP_NAME }}-${{ env.VERSION_TAG }}-windows-${{ matrix.arch_display }}-setup.exe
${{ env.APP_NAME }}-${{ env.VERSION_TAG }}-windows-${{ matrix.arch_display }}-portable.zip
hsync-${{ env.VERSION_TAG }}-windows-${{ matrix.arch_display }}-setup.exe
hsync-${{ env.VERSION_TAG }}-windows-${{ matrix.arch_display }}.zip
- name: Upload artifacts (macOS)
if: runner.os == 'macOS'
uses: actions/upload-artifact@v4
with:
name: pkg-${{ matrix.platform_name }}
path: |
${{ env.APP_NAME }}-${{ env.VERSION_TAG }}-macos-${{ matrix.arch_display }}.dmg
hsync-${{ env.VERSION_TAG }}-macos-${{ matrix.arch_display }}.tar.gz
- name: Upload artifacts (Linux)
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: pkg-${{ matrix.platform_name }}
path: |
${{ env.APP_NAME }}-${{ env.VERSION_TAG }}-linux-${{ matrix.arch_display }}.AppImage
${{ env.APP_ID }}_${{ env.VERSION_NUM }}_${{ matrix.deb_arch }}.deb
${{ env.APP_ID }}_${{ env.VERSION_NUM }}_${{ matrix.rpm_arch }}.rpm
${{ env.APP_NAME }}-${{ env.VERSION_TAG }}-linux-${{ matrix.arch_display }}.tar.gz
hsync-${{ env.VERSION_TAG }}-linux-${{ matrix.arch_display }}.tar.gz