License gate: enforce only in direct-download builds #34
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 | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| workflow_dispatch: | |
| jobs: | |
| build: | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [windows-latest, macos-latest] | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-python@v6 | |
| with: | |
| python-version: "3.12" | |
| - name: Install dependencies | |
| run: python -m pip install --upgrade pip && pip install -r requirements-dev.txt | |
| - name: Run tests | |
| run: python -m pytest tests/ -v | |
| # Direct-download builds (macOS .dmg) enforce the Paddle license gate by | |
| # bundling this flag file; the Windows leg omits it so the Microsoft Store | |
| # MSIX is not double-gated. See app/config.py:LICENSE_REQUIRED. | |
| - name: Enable license gate for direct-download build | |
| if: matrix.os == 'macos-latest' | |
| run: touch app/resources/license_required.flag | |
| - name: Build with PyInstaller | |
| run: pyinstaller packaging/build_exe.spec --noconfirm --distpath dist --workpath build | |
| # On macOS, package the .app bundle with ditto. This preserves the | |
| # executable bit, the symlinked Qt frameworks, and PyInstaller's ad-hoc | |
| # code signature — all of which a plain cross-platform zip (or an | |
| # artifact round-trip through Windows) destroys. arm64 macOS refuses to | |
| # launch an app whose signature is invalid, so this native archive is | |
| # the file to distribute to end users. macos-latest runners are Apple | |
| # Silicon, so this is an arm64 build. | |
| - name: Package macOS app (ditto) | |
| if: matrix.os == 'macos-latest' | |
| run: ditto -c -k --keepParent dist/EasyPostDesktop.app "dist/EasyPostDesktop-macOS-arm64.zip" | |
| # Sign with a Developer ID cert + hardened runtime, notarize with Apple, | |
| # staple, and produce a distributable .dmg. Entirely gated on the Apple | |
| # signing secrets below: until they exist this is a no-op and the ad-hoc | |
| # ditto zip above stays the macOS artifact. Add these repo secrets once | |
| # enrolled in the Apple Developer Program: | |
| # MACOS_CERTIFICATE_P12_BASE64 base64 of the Developer ID Application .p12 | |
| # MACOS_CERTIFICATE_PASSWORD password for that .p12 | |
| # MACOS_SIGN_IDENTITY e.g. "Developer ID Application: Name (TEAMID)" | |
| # APPLE_ID Apple ID email used for notarization | |
| # APPLE_APP_PASSWORD app-specific password for that Apple ID | |
| # APPLE_TEAM_ID 10-character Apple Developer Team ID | |
| - name: Sign, notarize & package macOS (.dmg) | |
| if: matrix.os == 'macos-latest' && env.HAS_MACOS_SIGNING == 'true' | |
| env: | |
| HAS_MACOS_SIGNING: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 != '' }} | |
| MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} | |
| MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} | |
| MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| set -euo pipefail | |
| APP="dist/EasyPostDesktop.app" | |
| DMG="dist/EasyPostDesktop-macOS-arm64.dmg" | |
| # 1) Import the Developer ID cert into a throwaway keychain. | |
| KEYCHAIN="$RUNNER_TEMP/build.keychain" | |
| KEYCHAIN_PW="$(uuidgen)" | |
| security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN" | |
| security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" | |
| echo "$MACOS_CERTIFICATE_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12" | |
| security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign | |
| security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PW" "$KEYCHAIN" >/dev/null | |
| security list-keychains -d user -s "$KEYCHAIN" | |
| # 2) Codesign the app: hardened runtime, secure timestamp, entitlements. | |
| codesign --force --deep --options runtime --timestamp \ | |
| --entitlements packaging/entitlements.mac.plist \ | |
| --sign "$MACOS_SIGN_IDENTITY" "$APP" | |
| codesign --verify --strict --verbose=2 "$APP" | |
| # 3) Build a compressed .dmg and sign it. | |
| hdiutil create -volname "Easy-Post Desktop" -srcfolder "$APP" -ov -format UDZO "$DMG" | |
| codesign --force --timestamp --sign "$MACOS_SIGN_IDENTITY" "$DMG" | |
| # 4) Notarize and staple. | |
| xcrun notarytool submit "$DMG" \ | |
| --apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" --team-id "$APPLE_TEAM_ID" --wait | |
| xcrun stapler staple "$DMG" | |
| xcrun stapler validate "$DMG" | |
| security delete-keychain "$KEYCHAIN" | |
| # Packages dist/EasyPostDesktop/ (the PyInstaller output above) into | |
| # dist/EasyPostDesktop.msix for Microsoft Store submission via Partner | |
| # Center — see packaging/build_msix.py and README. | |
| - name: Build MSIX package | |
| if: matrix.os == 'windows-latest' | |
| run: python packaging\build_msix.py | |
| # MSIX/AppX packages must carry *some* signature to be structurally | |
| # valid, but the Microsoft Store re-signs with its own certificate | |
| # during publishing — a self-signed one is accepted for submission, so | |
| # this generates and discards a throwaway cert per run rather than | |
| # needing a purchased cert or any stored secret. Uses the same | |
| # sign-only script (packaging/sign_msix.ps1) as local upload signing: | |
| # it builds the throwaway cert in managed .NET (CertificateRequest), | |
| # which avoids New-SelfSignedCertificate/certreq entirely. See: | |
| # https://learn.microsoft.com/windows/apps/publish/publish-your-app/msix/app-package-requirements | |
| - name: Sign MSIX package | |
| if: matrix.os == 'windows-latest' | |
| shell: pwsh | |
| run: pwsh -NoProfile -File packaging\sign_msix.ps1 | |
| # Unsigned builds still trigger SmartScreen/Gatekeeper warnings on first | |
| # run regardless of this step — only a paid code-signing certificate | |
| # (see README) actually removes that. This just gives users a way to | |
| # verify the download wasn't tampered with in transit. | |
| - name: Generate SHA256 checksums | |
| shell: python | |
| run: | | |
| import hashlib | |
| from pathlib import Path | |
| dist = Path("dist") | |
| lines = [] | |
| for path in sorted(dist.rglob("*")): | |
| if path.is_file(): | |
| digest = hashlib.sha256(path.read_bytes()).hexdigest() | |
| lines.append(f"{digest} {path.relative_to(dist)}") | |
| Path("dist/SHA256SUMS.txt").write_text("\n".join(lines) + "\n") | |
| # No-op unless WINDOWS_CODE_SIGNING_CERT_BASE64/_PASSWORD secrets are | |
| # set on the repo. Wire these up if a certificate is purchased later | |
| # (see README's "Windows SmartScreen warning" section) — everything | |
| # else in this workflow already works either way. | |
| - name: Sign Windows build | |
| if: matrix.os == 'windows-latest' && env.HAS_SIGNING_CERT == 'true' | |
| env: | |
| HAS_SIGNING_CERT: ${{ secrets.WINDOWS_CODE_SIGNING_CERT_BASE64 != '' }} | |
| shell: pwsh | |
| run: | | |
| $bytes = [Convert]::FromBase64String("${{ secrets.WINDOWS_CODE_SIGNING_CERT_BASE64 }}") | |
| [IO.File]::WriteAllBytes("cert.pfx", $bytes) | |
| $signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | | |
| Sort-Object FullName -Descending | Select-Object -First 1 | |
| & $signtool.FullName sign /f cert.pfx /p "${{ secrets.WINDOWS_CODE_SIGNING_CERT_PASSWORD }}" ` | |
| /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 ` | |
| dist\EasyPostDesktop\EasyPostDesktop.exe | |
| Remove-Item cert.pfx | |
| - name: Upload build artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: EasyPostDesktop-${{ matrix.os }} | |
| path: dist/* |