v2.1.8: backup name filters prefer exact match #38
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: 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 |