Skip to content

v2.1.8: backup name filters prefer exact match #38

v2.1.8: backup name filters prefer exact match

v2.1.8: backup name filters prefer exact match #38

Workflow file for this run

name: Build & Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pyinstaller
# ─────────────────────────────────────────────────────────────────
# Code-signing & notarization scaffolding
# ─────────────────────────────────────────────────────────────────
# All signing/notarization steps below activate automatically when
# the required GitHub repository secrets are populated. Until then,
# they are skipped and the build produces an unsigned DMG exactly
# as before. Required secrets:
#
# APPLE_DEVELOPER_ID_CERT_P12 (base64 of .p12)
# APPLE_DEVELOPER_ID_CERT_PASSWORD (.p12 password)
# APPLE_NOTARY_API_KEY_ID (e.g. ABCD123456)
# APPLE_NOTARY_API_KEY_ISSUER_ID (UUID)
# APPLE_NOTARY_API_KEY_P8 (base64 of .p8)
#
# Optional:
# APPLE_TEAM_ID (10-char team ID; auto-
# detected from the cert
# if omitted)
# ─────────────────────────────────────────────────────────────────
- name: Check for signing secrets
id: signing
env:
CERT_P12: ${{ secrets.APPLE_DEVELOPER_ID_CERT_P12 }}
CERT_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERT_PASSWORD }}
NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_API_KEY_ID }}
NOTARY_ISSUER: ${{ secrets.APPLE_NOTARY_API_KEY_ISSUER_ID }}
NOTARY_KEY_P8: ${{ secrets.APPLE_NOTARY_API_KEY_P8 }}
run: |
if [ -n "$CERT_P12" ] && [ -n "$CERT_PASSWORD" ]; then
echo "sign=true" >> "$GITHUB_OUTPUT"
echo "✅ codesign secrets present — will sign the .app and DMG"
else
echo "sign=false" >> "$GITHUB_OUTPUT"
echo "⚠ codesign secrets missing — unsigned build"
fi
if [ -n "$NOTARY_KEY_ID" ] && [ -n "$NOTARY_ISSUER" ] && [ -n "$NOTARY_KEY_P8" ]; then
echo "notarize=true" >> "$GITHUB_OUTPUT"
echo "✅ notary secrets present — will notarize + staple DMG"
else
echo "notarize=false" >> "$GITHUB_OUTPUT"
echo "⚠ notary secrets missing — DMG will be signed but not notarized"
fi
- name: Import codesigning certificate
if: steps.signing.outputs.sign == 'true'
env:
CERT_P12: ${{ secrets.APPLE_DEVELOPER_ID_CERT_P12 }}
CERT_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERT_PASSWORD }}
run: |
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -hex 16)"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV"
# Create + unlock an ephemeral keychain so the cert never lands
# in the runner's login keychain.
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Decode cert + import it
CERT_FILE="$RUNNER_TEMP/cert.p12"
echo -n "$CERT_P12" | base64 --decode -o "$CERT_FILE"
security import "$CERT_FILE" \
-k "$KEYCHAIN_PATH" \
-P "$CERT_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security
rm -f "$CERT_FILE"
# Allow codesign to use the key without a password prompt
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Make the new keychain the default search path (preserving the
# existing keychains so other tools keep working).
security list-keychains -d user -s "$KEYCHAIN_PATH" \
$(security list-keychains -d user | sed 's/"//g')
# Cache the identity name for later steps
IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \
| grep 'Developer ID Application' \
| head -n1 \
| awk -F'"' '{print $2}')"
if [ -z "$IDENTITY" ]; then
echo "❌ Could not find a 'Developer ID Application' identity in the imported keychain"
security find-identity -v -p codesigning "$KEYCHAIN_PATH" || true
exit 1
fi
echo "SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
echo "Using signing identity: $IDENTITY"
- name: Build macOS app
env:
SOURCE_DATE_EPOCH: '1750000000'
PYTHONHASHSEED: '0'
run: |
pyinstaller \
--name "S1 Command Center" \
--windowed \
--onedir \
--icon s1cc.icns \
--add-data "s1cc.ico:." \
--add-data "s1cc.icns:." \
--add-data "export_utils.py:." \
--hidden-import customtkinter \
--hidden-import openpyxl \
--hidden-import PIL \
--collect-all customtkinter \
--noconfirm \
main.py
- name: Codesign .app bundle
if: steps.signing.outputs.sign == 'true'
run: |
APP="dist/S1 Command Center.app"
# Strip any stale signatures + quarantine before re-signing.
xattr -cr "$APP"
# Sign every embedded binary first (deep), then the outer bundle.
# --options runtime enables hardened runtime (required by notary).
# --timestamp embeds a trusted Apple timestamp (required by notary).
codesign --force --deep \
--options runtime \
--timestamp \
--entitlements installer/entitlements.plist \
--sign "$SIGNING_IDENTITY" \
"$APP"
echo "── verifying signature ────────────────────────────────"
codesign --verify --deep --strict --verbose=2 "$APP"
echo "── spctl assessment ───────────────────────────────────"
spctl -a -vvv -t exec "$APP" || true
- name: Stage DMG contents
# Drag-to-Applications layout: the user drags the .app onto the
# Applications symlink we ship inside the DMG. We strip the
# quarantine xattr from the .app first so the bytes inside the
# DMG are clean (macOS will re-apply quarantine on download,
# but at least the DMG itself isn't carrying stale xattrs that
# confuse Gatekeeper's content-hash "Open Anyway" memory).
run: |
xattr -cr "dist/S1 Command Center.app"
DMG_STAGE="dist/dmg_stage"
rm -rf "$DMG_STAGE"
mkdir -p "$DMG_STAGE"
cp -R "dist/S1 Command Center.app" "$DMG_STAGE/"
ln -s /Applications "$DMG_STAGE/Applications"
cp "installer/README.txt" "$DMG_STAGE/README.txt"
- name: Create DMG
run: |
hdiutil create -volname "S1 Command Center" \
-srcfolder "dist/dmg_stage" \
-ov -format UDZO "dist/S1-Command-Center-macOS.dmg"
xattr -cr "dist/S1-Command-Center-macOS.dmg"
- name: Codesign DMG
if: steps.signing.outputs.sign == 'true'
run: |
codesign --force \
--sign "$SIGNING_IDENTITY" \
--timestamp \
"dist/S1-Command-Center-macOS.dmg"
codesign --verify --verbose=2 "dist/S1-Command-Center-macOS.dmg"
- name: Notarize + staple DMG
if: steps.signing.outputs.notarize == 'true'
env:
NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_API_KEY_ID }}
NOTARY_ISSUER: ${{ secrets.APPLE_NOTARY_API_KEY_ISSUER_ID }}
NOTARY_KEY_P8: ${{ secrets.APPLE_NOTARY_API_KEY_P8 }}
run: |
KEY_FILE="$RUNNER_TEMP/notary_key.p8"
echo -n "$NOTARY_KEY_P8" | base64 --decode -o "$KEY_FILE"
echo "── submitting to Apple notary service (may take a few minutes)…"
xcrun notarytool submit "dist/S1-Command-Center-macOS.dmg" \
--key "$KEY_FILE" \
--key-id "$NOTARY_KEY_ID" \
--issuer "$NOTARY_ISSUER" \
--wait \
--timeout 30m
echo "── stapling notarization ticket ───────────────────────"
xcrun stapler staple "dist/S1-Command-Center-macOS.dmg"
xcrun stapler validate "dist/S1-Command-Center-macOS.dmg"
rm -f "$KEY_FILE"
- name: Clean up signing keychain
if: always() && steps.signing.outputs.sign == 'true'
run: |
security delete-keychain "$KEYCHAIN_PATH" || true
- name: Upload macOS artifact
uses: actions/upload-artifact@v4
with:
name: S1-Command-Center-macOS
path: dist/S1-Command-Center-macOS.dmg
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
# ─────────────────────────────────────────────────────────────────
# Build PyInstaller bootloader from source
# ─────────────────────────────────────────────────────────────────
# The pre-built bootloader binary is shared by thousands of
# PyInstaller apps (including malware) and is a known false-
# positive trigger for heuristic AV/EDR engines. Compiling from
# source produces a unique bootloader hash for our build.
# ─────────────────────────────────────────────────────────────────
- name: Install dependencies & build PyInstaller bootloader from source
shell: pwsh
run: |
pip install -r requirements.txt
pip install pyinstaller
$pyiVer = (pip show pyinstaller | Select-String '^Version:').ToString().Split(' ')[1]
Write-Host "PyInstaller version: $pyiVer"
pip uninstall pyinstaller pyinstaller-hooks-contrib -y
git clone --depth 1 --branch "v$pyiVer" https://github.com/pyinstaller/pyinstaller.git _pyinstaller_src
Push-Location _pyinstaller_src\bootloader
python ./waf distclean all
Pop-Location
pip install .\_pyinstaller_src
Remove-Item _pyinstaller_src -Recurse -Force
Write-Host "✅ PyInstaller $pyiVer with custom bootloader installed"
# ─────────────────────────────────────────────────────────────────
# Windows code-signing scaffolding
# ─────────────────────────────────────────────────────────────────
# All signing steps activate automatically when the required
# GitHub repository secrets are populated. Until then, they are
# skipped and the build produces an unsigned exe exactly as
# before. Required secrets:
#
# WINDOWS_SIGN_CERT_P12 (base64 of .pfx / .p12)
# WINDOWS_SIGN_CERT_PASSWORD (.pfx password)
#
# The certificate should be an EV or OV code-signing cert from
# a trusted CA. EV certs give immediate SmartScreen reputation;
# OV certs build reputation over time.
# ─────────────────────────────────────────────────────────────────
- name: Check for signing secrets
id: winsign
shell: pwsh
env:
CERT_P12: ${{ secrets.WINDOWS_SIGN_CERT_P12 }}
CERT_PASSWORD: ${{ secrets.WINDOWS_SIGN_CERT_PASSWORD }}
run: |
if ($env:CERT_P12 -and $env:CERT_PASSWORD) {
"sign=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
Write-Host "✅ Windows code-signing secrets present"
} else {
"sign=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
Write-Host "⚠ Windows code-signing secrets missing — unsigned build"
}
- name: Import code-signing certificate
if: steps.winsign.outputs.sign == 'true'
shell: pwsh
env:
CERT_P12: ${{ secrets.WINDOWS_SIGN_CERT_P12 }}
CERT_PASSWORD: ${{ secrets.WINDOWS_SIGN_CERT_PASSWORD }}
run: |
$certFile = "$env:RUNNER_TEMP\codesign.pfx"
[IO.File]::WriteAllBytes($certFile, [Convert]::FromBase64String($env:CERT_P12))
Import-PfxCertificate -FilePath $certFile `
-CertStoreLocation Cert:\CurrentUser\My `
-Password (ConvertTo-SecureString $env:CERT_PASSWORD -AsPlainText -Force)
Remove-Item $certFile -Force
$thumb = (Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert | Select-Object -First 1).Thumbprint
"SIGN_THUMBPRINT=$thumb" | Out-File -FilePath $env:GITHUB_ENV -Append
Write-Host "Certificate thumbprint: $thumb"
- name: Build Windows exe
env:
SOURCE_DATE_EPOCH: '1750000000'
PYTHONHASHSEED: '0'
run: |
pyinstaller `
--name "S1 Command Center" `
--windowed `
--onedir `
--icon s1cc.ico `
--version-file version_info.txt `
--add-data "s1cc.ico;." `
--add-data "export_utils.py;." `
--hidden-import customtkinter `
--hidden-import openpyxl `
--hidden-import PIL `
--collect-all customtkinter `
--noconfirm `
main.py
- name: Sign application binaries
if: steps.winsign.outputs.sign == 'true'
shell: pwsh
run: |
$signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\*\bin\*\x64\signtool.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $signtool) { throw "signtool.exe not found" }
$st = $signtool.FullName
Write-Host "Using: $st"
# Sign the main exe + all DLLs/PYDs in the bundle
$targets = Get-ChildItem "dist\S1 Command Center" -Recurse -Include *.exe,*.dll,*.pyd
foreach ($f in $targets) {
Write-Host " Signing $($f.Name)…"
& $st sign /sha1 $env:SIGN_THUMBPRINT /fd sha256 /tr http://timestamp.digicert.com /td sha256 $f.FullName
if ($LASTEXITCODE -ne 0) { Write-Warning "Failed to sign $($f.Name) — continuing" }
}
Write-Host "✅ Signed $($targets.Count) files"
- name: Create ZIP
run: |
Compress-Archive -Path "dist\S1 Command Center" -DestinationPath "dist\S1-Command-Center-Windows.zip"
- name: Resolve installer version
id: ver
shell: pwsh
run: |
$ref = "${{ github.ref_name }}"
if ($ref -match '^v(.+)$') { $v = $Matches[1] } else { $v = "0.0.0" }
"version=$v" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
Write-Host "Installer version: $v"
- name: Build Inno Setup installer
shell: pwsh
run: |
$iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (-not (Test-Path $iscc)) {
throw "Inno Setup 6 not found at $iscc"
}
& $iscc "/DAppVersion=${{ steps.ver.outputs.version }}" "installer\windows.iss"
if ($LASTEXITCODE -ne 0) { throw "ISCC failed with exit code $LASTEXITCODE" }
Rename-Item -Path "dist\S1-Command-Center-Setup.exe" -NewName "S1-Command-Center-Windows-Setup.exe"
- name: Sign installer
if: steps.winsign.outputs.sign == 'true'
shell: pwsh
run: |
$signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\*\bin\*\x64\signtool.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
& $signtool.FullName sign /sha1 $env:SIGN_THUMBPRINT /fd sha256 /tr http://timestamp.digicert.com /td sha256 "dist\S1-Command-Center-Windows-Setup.exe"
Write-Host "✅ Installer signed"
- name: Upload Windows ZIP artifact
uses: actions/upload-artifact@v4
with:
name: S1-Command-Center-Windows
path: dist/S1-Command-Center-Windows.zip
- name: Upload Windows Installer artifact
uses: actions/upload-artifact@v4
with:
name: S1-Command-Center-Windows-Setup
path: dist/S1-Command-Center-Windows-Setup.exe
release:
needs: [build-macos, build-windows]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Checkout (for changelog + release footer)
uses: actions/checkout@v4
- name: Download macOS artifact
uses: actions/download-artifact@v4
with:
name: S1-Command-Center-macOS
- name: Download Windows ZIP artifact
uses: actions/download-artifact@v4
with:
name: S1-Command-Center-Windows
- name: Download Windows Installer artifact
uses: actions/download-artifact@v4
with:
name: S1-Command-Center-Windows-Setup
- name: Build release notes (changelog + install footer)
env:
TAG: ${{ github.ref_name }}
run: |
# Pull this version's section out of the wiki changelog (body only —
# everything between "## <tag>" and the next "## v" header).
awk -v hdr="## $TAG" 'index($0,hdr)==1{f=1;next} f&&/^## v/{exit} f{print}' \
wiki/Changelog.md > SECTION.md || true
{
echo "## S1 Command Center $TAG"
echo
if [ -s SECTION.md ]; then cat SECTION.md; echo; fi
cat .github/RELEASE_FOOTER.md
} > RELEASE_NOTES.md
echo "----- RELEASE_NOTES.md -----"
cat RELEASE_NOTES.md
- name: Create Release
uses: softprops/action-gh-release@v2
with:
name: S1 Command Center ${{ github.ref_name }}
body_path: RELEASE_NOTES.md
files: |
S1-Command-Center-macOS.dmg
S1-Command-Center-Windows.zip
S1-Command-Center-Windows-Setup.exe