Give the site an app banner, real store badges, and attributed links … #219
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] | |
| # The marketing site and prose changes cannot affect the binaries, and a | |
| # macOS leg costs ~10 minutes plus a notarization wait. store_assets holds | |
| # only Partner Center listing artifacts (screenshots, listing CSVs, the | |
| # import builder) — the app bundles app/resources, never store_assets — so | |
| # editing it must not fire the matrix or, worse, mint another App Store | |
| # build off github.run_number. Skip them all. | |
| paths-ignore: | |
| - "site/**" | |
| - "store_assets/**" | |
| - "**/*.md" | |
| - "LICENSE" | |
| # No paths-ignore here, deliberately. `build (macos-latest)` and | |
| # `build (windows-latest)` are required status checks on main, and a workflow | |
| # that never triggers reports nothing at all — so a pull request touching only | |
| # site/ or a .md file would sit BLOCKED for ever, waiting on checks that can | |
| # never arrive. Filtering paths on push is fine; pushes are not gated. | |
| # | |
| # The cost is a build on documentation-only pull requests. That is acceptable | |
| # now that the publishing steps below are guarded: a pull-request build | |
| # compiles and tests, and signs, notarizes or uploads nothing. | |
| pull_request: | |
| workflow_dispatch: | |
| inputs: | |
| locale: | |
| description: "UI languages for store screenshots, comma-separated (e.g. en,fr,de,ja)" | |
| required: false | |
| default: "en" | |
| screenshots_only: | |
| description: "Render screenshots without building or uploading anything" | |
| type: boolean | |
| required: false | |
| default: false | |
| jobs: | |
| # A dispatch aimed at screenshots must not run the build matrix. That matrix | |
| # signs, notarizes and uploads a Mac App Store package, so every dispatch mints | |
| # a build off github.run_number — capturing seven locales would have burned | |
| # seven build numbers and seven notarization waits to produce no binary anyone | |
| # wanted. The screenshots job needs neither signing nor packaging. | |
| build: | |
| if: ${{ github.event.inputs.screenshots_only != 'true' }} | |
| permissions: | |
| id-token: write # OIDC federated-credential exchange for azure/login | |
| contents: read | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [windows-latest, macos-latest] | |
| runs-on: ${{ matrix.os }} | |
| # Backstop for the whole job. GitHub's default is 6 hours, which is far | |
| # longer than anything here should legitimately take. | |
| timeout-minutes: 90 | |
| 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 | |
| # The MCP bridge is direct-download only. The Windows leg builds the | |
| # Store MSIX from the same tree, so the flag is written after the MSIX | |
| # is packaged rather than here -- see "Enable MCP for the direct-download | |
| # Windows build" further down. | |
| - name: Enable MCP bridge for the macOS build | |
| if: matrix.os == 'macos-latest' | |
| run: touch app/resources/mcp_supported.flag | |
| # The Windows leg's FIRST PyInstaller build feeds the Store MSIX, so the | |
| # Store-variant flag must exist now, before that build. It is removed | |
| # again before the direct-download rebuild further down. store_build.flag | |
| # makes app/config.STORE_BUILD true, gating production behind the Store | |
| # "Production unlock" add-on (see app/core/store_entitlement.py). | |
| - name: Mark the Windows Store build | |
| if: matrix.os == 'windows-latest' | |
| shell: bash | |
| run: touch app/resources/store_build.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 | |
| # Before notarization, not after: a bundle missing its flags must fail | |
| # here rather than after half an hour in Apple's queue. | |
| - name: Verify variant flags survived packaging (macOS) | |
| if: matrix.os == 'macos-latest' | |
| run: bash packaging/verify_variant_flags.sh dist/EasyPostDesktop.app | |
| # Never on a pull request. Notarization is a paid round-trip to Apple that | |
| # tells a reviewer nothing a plain build does not, and the Windows signing | |
| # step below has excluded pull requests all along — this only makes the | |
| # two consistent. | |
| - name: Sign, notarize & package macOS (.dmg) | |
| if: matrix.os == 'macos-latest' && env.HAS_MACOS_SIGNING == 'true' && github.event_name != 'pull_request' | |
| # Hard ceiling on the whole step. Run 29849490422 sat here for 1h58m: | |
| # the DMG uploaded fine, then `notarytool --wait` silently retried a | |
| # dead runner network until it gave up. Without this the job would | |
| # have run to GitHub's 6-hour default. | |
| timeout-minutes: 45 | |
| 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. | |
| # | |
| # Submit and wait are deliberately separate calls. `submit --wait` | |
| # prints the submission id only to stdout as it goes, so when the | |
| # wait died mid-poll the id was buried in a 1000-line log and the | |
| # build looked like it had simply hung. Capturing the id first means | |
| # it is always recorded and always queryable afterwards, whatever | |
| # happens to the polling. | |
| AUTH=(--apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" --team-id "$APPLE_TEAM_ID") | |
| SUBMISSION_ID="$( | |
| xcrun notarytool submit "$DMG" "${AUTH[@]}" --no-wait --output-format json \ | |
| | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' | |
| )" | |
| echo "Notarization submission id: $SUBMISSION_ID" | |
| { | |
| echo "### macOS notarization" | |
| echo "- Submission id: \`$SUBMISSION_ID\`" | |
| echo "- Query later: \`Notarization status\` workflow, or" | |
| echo " \`xcrun notarytool info $SUBMISSION_ID --apple-id … --team-id …\`" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # Bounded wait. Apple normally answers within minutes; 30m is | |
| # generous without letting a stalled poll eat the whole job. | |
| if ! xcrun notarytool wait "$SUBMISSION_ID" "${AUTH[@]}" --timeout 30m; then | |
| echo "::error::Notarization did not reach Accepted within the timeout." | |
| echo "Fetching whatever Apple has recorded for $SUBMISSION_ID:" | |
| xcrun notarytool info "$SUBMISSION_ID" "${AUTH[@]}" || true | |
| xcrun notarytool log "$SUBMISSION_ID" "${AUTH[@]}" || true | |
| exit 1 | |
| fi | |
| # A rejected submission still "completes", so check the verdict and | |
| # print Apple's log, which names the exact offending binary. | |
| STATUS="$(xcrun notarytool info "$SUBMISSION_ID" "${AUTH[@]}" --output-format json \ | |
| | python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')" | |
| echo "Notarization status: $STATUS" | |
| if [ "$STATUS" != "Accepted" ]; then | |
| echo "::error::Notarization returned $STATUS. Apple's log follows." | |
| xcrun notarytool log "$SUBMISSION_ID" "${AUTH[@]}" || true | |
| exit 1 | |
| fi | |
| xcrun stapler staple "$DMG" | |
| xcrun stapler validate "$DMG" | |
| security delete-keychain "$KEYCHAIN" | |
| # Mac App Store package: build the sandboxed .app with the MAS variant | |
| # flag, sign it with the Apple Distribution + 3rd Party Mac Developer | |
| # Installer certs, wrap it in a signed .pkg (productbuild), and upload to | |
| # App Store Connect. Entirely gated on the MAS signing secrets: until they | |
| # exist this is a no-op and the App Store build stays a manual Mac task. | |
| # See CI-MAS-SETUP.md for how to add these repo secrets: | |
| # MAS_CERTIFICATE_P12_BASE64 base64 of a .p12 holding BOTH the Apple | |
| # Distribution and 3rd Party Mac Developer | |
| # Installer certs (+ their private keys) | |
| # MAS_CERTIFICATE_PASSWORD password for that .p12 | |
| # MAS_PROVISION_PROFILE_BASE64 base64 of the Mac App Store .provisionprofile | |
| # MAS_SIGN_APP_IDENTITY "Apple Distribution: NAME (TEAMID)" | |
| # MAS_SIGN_INSTALLER_IDENTITY "3rd Party Mac Developer Installer: NAME (TEAMID)" | |
| # Upload authenticates with an App Store Connect API key (key | |
| # 4CU796U485, App Manager). It replaced an Admin key that CI never used | |
| # and that sat in cloud-synced storage; App Manager is the least privilege | |
| # that can still manage versions, metadata and submissions. | |
| # | |
| # The Apple ID and app-specific password remain for *notarisation* of the | |
| # direct-download .dmg, which is a separate flow and is left alone. | |
| # Never on a pull request, and this one matters more than the notarization | |
| # guard above: the step stamps CFBundleVersion from github.run_number and | |
| # uploads to App Store Connect, so an unguarded pull-request build mints a | |
| # real Mac App Store build off a branch nobody has merged. | |
| - name: Build & upload Mac App Store package (.pkg) | |
| if: matrix.os == 'macos-latest' && env.HAS_MAS_SIGNING == 'true' && github.event_name != 'pull_request' | |
| timeout-minutes: 30 | |
| env: | |
| HAS_MAS_SIGNING: ${{ secrets.MAS_CERTIFICATE_P12_BASE64 != '' }} | |
| MAS_CERTIFICATE_P12_BASE64: ${{ secrets.MAS_CERTIFICATE_P12_BASE64 }} | |
| MAS_CERTIFICATE_PASSWORD: ${{ secrets.MAS_CERTIFICATE_PASSWORD }} | |
| MAS_PROVISION_PROFILE_BASE64: ${{ secrets.MAS_PROVISION_PROFILE_BASE64 }} | |
| SIGN_APP_IDENTITY: ${{ secrets.MAS_SIGN_APP_IDENTITY }} | |
| SIGN_INSTALLER_IDENTITY: ${{ secrets.MAS_SIGN_INSTALLER_IDENTITY }} | |
| ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} | |
| ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} | |
| BUILD_NUMBER: ${{ github.run_number }} | |
| run: | | |
| set -euo pipefail | |
| # 1) Import the signing certs into a throwaway keychain. | |
| KEYCHAIN="$RUNNER_TEMP/mas.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 "$MAS_CERTIFICATE_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/mas.p12" | |
| security import "$RUNNER_TEMP/mas.p12" -k "$KEYCHAIN" -P "$MAS_CERTIFICATE_PASSWORD" \ | |
| -T /usr/bin/codesign -T /usr/bin/productbuild | |
| security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PW" "$KEYCHAIN" >/dev/null | |
| security list-keychains -d user -s "$KEYCHAIN" | |
| # 2) Materialise the provisioning profile for build_mas.sh. | |
| echo "$MAS_PROVISION_PROFILE_BASE64" | base64 --decode > "$RUNNER_TEMP/app.provisionprofile" | |
| export PROVISION_PROFILE="$RUNNER_TEMP/app.provisionprofile" | |
| # 3) Stamp the version: marketing version from app.config.APP_VERSION, | |
| # build number from the monotonic CI run number (App Store Connect | |
| # rejects a re-used CFBundleVersion). | |
| APP_VERSION="$(python3 -c 'import re,pathlib; print(re.search(r"APP_VERSION\s*=\s*\"([^\"]+)\"", pathlib.Path("app/config.py").read_text()).group(1))')" | |
| PB=/usr/libexec/PlistBuddy | |
| ADD=packaging/mas/Info.plist.additions | |
| $PB -c "Set :CFBundleShortVersionString $APP_VERSION" "$ADD" | |
| $PB -c "Set :CFBundleVersion $BUILD_NUMBER" "$ADD" | |
| echo "MAS marketing version $APP_VERSION, build $BUILD_NUMBER" | |
| # 4) Build + sign the .pkg (skip build_mas.sh's interactive validate | |
| # hint; the upload below is the real thing). | |
| export PYTHON=python3 | |
| SKIP_VALIDATE=1 bash packaging/mas/build_mas.sh | |
| # 5) Upload to App Store Connect with the API key. | |
| # | |
| # altool resolves --apiKey by looking for AuthKey_<KeyID>.p8 in | |
| # ~/.appstoreconnect/private_keys (among others), so the key is | |
| # materialised there under exactly that name and removed afterwards. | |
| # The runner is ephemeral, but a key left on disk between steps is | |
| # readable by anything that runs after it, and this costs one line. | |
| KEYDIR="$HOME/.appstoreconnect/private_keys" | |
| mkdir -p "$KEYDIR" | |
| echo "$ASC_API_KEY_P8_BASE64" | base64 --decode > "$KEYDIR/AuthKey_$ASC_KEY_ID.p8" | |
| chmod 600 "$KEYDIR/AuthKey_$ASC_KEY_ID.p8" | |
| trap 'rm -f "$KEYDIR/AuthKey_$ASC_KEY_ID.p8"' EXIT | |
| xcrun altool --upload-app -f dist_mas/EasyPostDesktop.pkg -t macos \ | |
| --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" | |
| 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. | |
| # | |
| # Ordering matters: this runs while no variant flags exist, so the MSIX | |
| # is packaged from a Store-shaped build (no licence gate, no MCP). The | |
| # direct-download Windows build is produced afterwards, with the flags | |
| # in place, overwriting dist/EasyPostDesktop/. | |
| - name: Build MSIX package | |
| if: matrix.os == 'windows-latest' | |
| run: python packaging\build_msix.py | |
| # Now rebuild the same tree as the direct-download variant. The MSIX is | |
| # already written, so overwriting dist/EasyPostDesktop/ is safe, and it | |
| # is this second build that ships from the website. | |
| - name: Enable MCP + license gate for the direct-download Windows build | |
| if: matrix.os == 'windows-latest' | |
| shell: bash | |
| run: | | |
| # The MSIX is already packaged; this tree now becomes the direct | |
| # download, which is the Paddle-gated variant — never the Store one. | |
| rm -f app/resources/store_build.flag | |
| touch app/resources/license_required.flag | |
| touch app/resources/mcp_supported.flag | |
| - name: Rebuild Windows direct-download variant | |
| if: matrix.os == 'windows-latest' | |
| run: pyinstaller packaging/build_exe.spec --noconfirm --distpath dist --workpath build | |
| # A packaged build that quietly loses its variant flags would ship the | |
| # paid app ungated, which is exactly what happened before the flags were | |
| # added to the spec's datas. Fail the build rather than find out later. | |
| - name: Verify variant flags survived packaging (Windows) | |
| if: matrix.os == 'windows-latest' | |
| shell: bash | |
| run: bash packaging/verify_variant_flags.sh dist/EasyPostDesktop | |
| # 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 | |
| # Sign the direct-download Windows .exe via Azure Artifact Signing | |
| # (formerly Trusted Signing) — a Microsoft-managed, Public Trust | |
| # certificate, which is what removes the SmartScreen "unknown publisher" | |
| # warning. The Store MSIX is re-signed by Microsoft on publish, so only | |
| # the direct-download build needs this. | |
| # | |
| # No-op until the repo variable AZURE_SIGNING_READY is 'true'. The | |
| # AZURE_CLIENT_ID / TENANT_ID / SUBSCRIPTION_ID repo secrets authenticate | |
| # through an OIDC federated credential (app registration | |
| # EasyPostDesktop-GitHubActions, subject repo:sgf36/EasyPost:ref:refs/heads/main) | |
| # — no client secret exists to leak. Because the credential is bound to | |
| # that subject, signing can only ever happen on main. | |
| # | |
| # The signing action picks the Azure credential up from the CLI session | |
| # azure/login leaves behind, so the login step is not optional. | |
| # Account, endpoint and profile are read from the resource itself; see | |
| # CI-AZURE-SIGNING-SETUP.md before changing any of the three. | |
| - name: Azure login (OIDC) for signing | |
| if: matrix.os == 'windows-latest' && vars.AZURE_SIGNING_READY == 'true' && github.event_name != 'pull_request' | |
| uses: azure/login@v2 | |
| with: | |
| client-id: ${{ secrets.AZURE_CLIENT_ID }} | |
| tenant-id: ${{ secrets.AZURE_TENANT_ID }} | |
| subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} | |
| - name: Sign Windows build (Azure Artifact Signing) | |
| if: matrix.os == 'windows-latest' && vars.AZURE_SIGNING_READY == 'true' && github.event_name != 'pull_request' | |
| uses: azure/artifact-signing-action@v2 | |
| with: | |
| endpoint: https://weu.codesigning.azure.net/ | |
| signing-account-name: EasyPostDesktop | |
| # The profile is named for the publisher, not the product — one | |
| # profile signs everything the business ships. It is NOT named | |
| # EasyPostDesktop, which is the signing *account*. | |
| certificate-profile-name: SpencerFieldsSoftware | |
| # Both executables sit at the top of the folder, so no recursion: | |
| # EasyPostDesktop.exe is what people launch, easypost-mcp.exe ships | |
| # beside it in the direct-download variant. Leaving either unsigned | |
| # would leave a SmartScreen prompt on the build. | |
| files-folder: ${{ github.workspace }}\dist\EasyPostDesktop | |
| files-folder-filter: exe | |
| file-digest: SHA256 | |
| timestamp-rfc3161: http://timestamp.acs.microsoft.com | |
| timestamp-digest: SHA256 | |
| # A signing step that silently signs nothing looks exactly like one that | |
| # worked, and the failure would only surface as a SmartScreen prompt on | |
| # someone else's machine. Prove the chain and the timestamp validate | |
| # before the build is allowed to publish anything. | |
| - name: Verify Windows signature | |
| if: matrix.os == 'windows-latest' && vars.AZURE_SIGNING_READY == 'true' && github.event_name != 'pull_request' | |
| shell: pwsh | |
| run: | | |
| $signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | | |
| Sort-Object FullName -Descending | Select-Object -First 1 | |
| if (-not $signtool) { throw "signtool.exe not found on the runner." } | |
| $exes = Get-ChildItem "dist\EasyPostDesktop" -Filter *.exe | |
| if ($exes.Count -lt 2) { throw "Expected both EasyPostDesktop.exe and easypost-mcp.exe; found $($exes.Count)." } | |
| foreach ($exe in $exes) { | |
| & $signtool.FullName verify /pa /v $exe.FullName | |
| if ($LASTEXITCODE -ne 0) { throw "Signature verification failed on $($exe.Name)." } | |
| } | |
| Write-Host "Both executables carry a valid, timestamped Authenticode signature." | |
| # Runs AFTER the Azure signing steps above, deliberately. Signing | |
| # rewrites EasyPostDesktop.exe and easypost-mcp.exe in place, so a | |
| # checksum taken before it describes a file nobody will ever download — | |
| # and SHA256SUMS.txt is published on the download page as the thing | |
| # people verify against. Never move this above the signing steps. | |
| # | |
| # The macOS leg is still covered by Developer ID signing + notarization; | |
| # this list is what lets someone confirm the download was not 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") | |
| - name: Upload build artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: EasyPostDesktop-${{ matrix.os }} | |
| path: dist/* | |
| # Store screenshots, rendered by CI rather than captured by hand on a rented | |
| # Mac. The macOS leg produces genuinely Mac-looking assets — real system | |
| # fonts, real control metrics, real window chrome — because it runs on a | |
| # macOS runner with the native cocoa platform plugin; the app is simply never | |
| # shown, since QWidget.grab() paints an unmapped widget. | |
| # | |
| # Manual-only: screenshots are a release chore, not something every push needs. | |
| # The locale input is comma-separated so one dispatch can capture the whole | |
| # localised set. Actions expressions have no split() or replace() — fromJSON | |
| # is the only way into a matrix dimension — so the list is turned into JSON | |
| # here, in a shell, and consumed as a matrix below. | |
| locales: | |
| if: github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| list: ${{ steps.split.outputs.list }} | |
| steps: | |
| - id: split | |
| env: | |
| RAW: ${{ github.event.inputs.locale || 'en' }} | |
| run: | | |
| list=$(printf '%s' "$RAW" | tr -d '[:space:]' | tr ',' '\n' \ | |
| | grep -v '^$' | sort -u | jq -R . | jq -sc .) | |
| echo "list=$list" >> "$GITHUB_OUTPUT" | |
| echo "capturing: $list" | |
| screenshots: | |
| if: github.event_name == 'workflow_dispatch' | |
| needs: locales | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| target: | |
| - os: macos-latest | |
| platform: mac | |
| - os: windows-latest | |
| platform: windows | |
| locale: ${{ fromJSON(needs.locales.outputs.list) }} | |
| runs-on: ${{ matrix.target.os }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: actions/setup-python@v6 | |
| with: | |
| python-version: '3.12' | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -r requirements.txt | |
| # No credentials are configured on the runner, and the script stubs the | |
| # credential store regardless — a published screenshot must never contain | |
| # a real API key or data from a real EasyPost account. | |
| # Whole-window captures, not bare page widgets. Every image already on | |
| # the Microsoft Store and App Store listings shows the full application | |
| # — mode banner, navigation sidebar and all — so a bare view is | |
| # recognisably the same product but visibly not the same screenshot, and | |
| # looks wrong sitting beside the ones it did not replace. | |
| # The window list matches the nine slots the Microsoft Store listing | |
| # publishes, so the App Store and Partner Center sets tell the same story | |
| # rather than each showing whichever views someone remembered that day. | |
| - name: Render screenshots | |
| run: > | |
| python packaging/make_screenshots.py | |
| --platform ${{ matrix.target.platform }} | |
| --locale ${{ matrix.locale }} | |
| --window CreateShipmentView | |
| --window TrackingView | |
| --window AddressBookView | |
| --window BatchView | |
| --window HistoryView | |
| --window ReportsView | |
| --window HtsLookupView | |
| --window SettingsView | |
| --window PickupsView | |
| --out dist/screenshots | |
| - name: Upload screenshots | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: screenshots-${{ matrix.target.platform }}-${{ matrix.locale }} | |
| path: dist/screenshots/** |