ci: release packages (#165) #11
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: Desktop Release | |
| on: | |
| push: | |
| tags: ['desktop-v*'] | |
| workflow_dispatch: {} | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: desktop-release-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| # Create one draft before either platform uploads. It stays private until both | |
| # jobs and the final downloaded-asset validation succeed. | |
| prepare: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.resolve.outputs.version }} | |
| tag: ${{ steps.resolve.outputs.tag }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4 | |
| with: | |
| persist-credentials: false | |
| # apps/desktop/package.json is the single source of truth for the version. | |
| # The tag only selects which commit ships. Stamping the tag's version into | |
| # the tree instead would let `git tag desktop-v9.9.9 <any commit>` silently | |
| # rewrite that commit into a release nobody reviewed, so a disagreement is | |
| # a hard failure rather than a rewrite. | |
| - name: Resolve the desktop version | |
| id: resolve | |
| shell: bash | |
| env: | |
| TAG_NAME: ${{ github.ref_name }} | |
| IS_TAG: ${{ startsWith(github.ref, 'refs/tags/desktop-v') }} | |
| run: | | |
| set -euo pipefail | |
| version="$(node -p 'require("./apps/desktop/package.json").version')" | |
| if [ -z "$version" ]; then | |
| echo 'Could not resolve a desktop version' >&2 | |
| exit 1 | |
| fi | |
| if [ "$IS_TAG" = 'true' ]; then | |
| tag_version="${TAG_NAME#desktop-v}" | |
| if [ "$tag_version" != "$version" ]; then | |
| echo "::error::Tag ${TAG_NAME} does not match apps/desktop/package.json (${version})." >&2 | |
| echo 'Bump the package version on main first, then tag that commit.' >&2 | |
| exit 1 | |
| fi | |
| fi | |
| # electron-builder names the GitHub release `v${version}`; the tag we | |
| # push here (`desktop-v*`) only triggers the workflow. | |
| echo "version=${version}" >> "$GITHUB_OUTPUT" | |
| echo "tag=v${version}" >> "$GITHUB_OUTPUT" | |
| echo "Releasing desktop ${version} as tag v${version}." | |
| # A release built from a commit that never landed on main ships code no | |
| # review gate ever saw. `compare` reports `identical` or `behind` when the | |
| # commit is an ancestor of main, and `diverged`/`ahead` when it is not. | |
| - name: Require the tagged commit to be on main | |
| if: startsWith(github.ref, 'refs/tags/desktop-v') | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| status="$(gh api "repos/${GITHUB_REPOSITORY}/compare/main...${GITHUB_SHA}" --jq '.status')" | |
| case "$status" in | |
| identical|behind) echo "${GITHUB_SHA} is on main (${status})." ;; | |
| *) | |
| echo "::error::${GITHUB_SHA} is not on main (compare status: ${status})." >&2 | |
| exit 1 ;; | |
| esac | |
| - name: Mint releases-repo token | |
| if: startsWith(github.ref, 'refs/tags/desktop-v') | |
| id: releases_token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # pinned from v3.2.0 | |
| with: | |
| app-id: ${{ secrets.DESKTOP_RELEASES_APP_ID }} | |
| private-key: ${{ secrets.DESKTOP_RELEASES_APP_PRIVATE_KEY }} | |
| owner: PyModel | |
| repositories: pythinker-desktop-releases | |
| permission-contents: write | |
| - name: Create the draft release unless it already exists | |
| if: startsWith(github.ref, 'refs/tags/desktop-v') | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ steps.releases_token.outputs.token }} | |
| RELEASE_TAG: ${{ steps.resolve.outputs.tag }} | |
| RELEASE_REPO: PyModel/pythinker-desktop-releases | |
| run: | | |
| set -euo pipefail | |
| # Drafts are safe to resume. A published release is immutable here: | |
| # platform uploads use --clobber and must never replace live assets. | |
| if is_draft="$(gh release view "$RELEASE_TAG" --repo "$RELEASE_REPO" --json isDraft --jq '.isDraft' 2>/dev/null)"; then | |
| if [ "$is_draft" != 'true' ]; then | |
| echo "::error::Release ${RELEASE_TAG} already exists and is published; refusing to replace live assets." >&2 | |
| exit 1 | |
| fi | |
| echo "Draft release ${RELEASE_TAG} already exists; resuming it." | |
| exit 0 | |
| fi | |
| gh release create "$RELEASE_TAG" \ | |
| --repo "$RELEASE_REPO" \ | |
| --draft \ | |
| --title "$RELEASE_TAG" \ | |
| --notes 'Desktop build in progress. Assets appear as each platform finishes.' | |
| echo "Created draft release ${RELEASE_TAG}." | |
| mac: | |
| needs: prepare | |
| runs-on: macos-15 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6 | |
| - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6 | |
| with: | |
| node-version-file: .nvmrc | |
| cache: pnpm | |
| - run: pnpm install --frozen-lockfile | |
| - name: Build workspace | |
| run: pnpm --workspace-root run build | |
| - name: Stage desktop runtime | |
| working-directory: apps/desktop | |
| run: node --import tsx scripts/stage-runtime.ts | |
| # An unset GitHub secret interpolates to an empty string, and | |
| # electron-builder resolves an empty CSC_LINK as a certificate path | |
| # (path.resolve(appDir, '') === appDir), failing with "not a file". | |
| # Export only the variables that carry a value. | |
| - name: Resolve macOS signing credentials | |
| shell: bash | |
| env: | |
| IN_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} | |
| IN_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} | |
| IN_CSC_NAME: ${{ secrets.MAC_CSC_NAME }} | |
| IN_APPLE_ID: ${{ secrets.APPLE_ID }} | |
| IN_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} | |
| IN_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| IN_APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} | |
| IN_APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} | |
| IN_APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} | |
| run: | | |
| # A fixed heredoc marker lets a credential that happens to contain that | |
| # line close its own value early and turn the rest into environment | |
| # entries. Draw the delimiter at random so no secret can carry it. | |
| delimiter="EOF_$(openssl rand -hex 16)" | |
| for name in CSC_LINK CSC_KEY_PASSWORD CSC_NAME APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID APPLE_API_KEY_ID APPLE_API_ISSUER; do | |
| input="IN_${name}" | |
| value="${!input:-}" | |
| if [ -n "$value" ]; then printf '%s<<%s\n%s\n%s\n' "$name" "$delimiter" "$value" "$delimiter" >> "$GITHUB_ENV"; fi | |
| done | |
| # The App Store Connect key is held as base64 because it is a file, not a | |
| # string. notarytool and electron-builder both want a path, so materialize | |
| # it outside the workspace to keep it out of the packaged app. | |
| if [ -n "${IN_APPLE_API_KEY_P8:-}" ]; then | |
| key_path="${RUNNER_TEMP}/AuthKey.p8" | |
| printf '%s' "$IN_APPLE_API_KEY_P8" | base64 -d > "$key_path" | |
| chmod 600 "$key_path" | |
| echo "APPLE_API_KEY=${key_path}" >> "$GITHUB_ENV" | |
| fi | |
| if [ -z "${IN_CSC_LINK:-}" ]; then | |
| echo 'CSC_IDENTITY_AUTO_DISCOVERY=false' >> "$GITHUB_ENV" | |
| echo 'No macOS signing certificate configured; building unsigned.' | |
| fi | |
| # A tag build that quietly produces an unsigned app is worse than a failed | |
| # one: macOS rejects unsigned updates, so it ships a release users cannot | |
| # install or update from. Manual runs stay free to build unsigned. | |
| - name: Require signing for tagged releases | |
| if: startsWith(github.ref, 'refs/tags/desktop-v') | |
| working-directory: apps/desktop | |
| run: node --import tsx scripts/assert-release-signing.ts | |
| - name: Package desktop release | |
| working-directory: apps/desktop | |
| run: pnpm exec electron-builder --mac dmg zip --publish never | |
| - name: Verify macOS update artifacts | |
| shell: bash | |
| env: | |
| DESKTOP_VERSION: ${{ needs.prepare.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| app_bundle="$(find apps/desktop/dist -maxdepth 2 -type d -name '*.app' -print -quit)" | |
| if [ -z "$app_bundle" ]; then | |
| echo 'macOS application bundle not found' >&2 | |
| exit 1 | |
| fi | |
| test -f "$app_bundle/Contents/Resources/app-update.yml" | |
| node --import tsx apps/desktop/scripts/verify-update-manifest.ts mac apps/desktop/dist "$DESKTOP_VERSION" | |
| - name: Verify macOS signatures and notarization | |
| if: startsWith(github.ref, 'refs/tags/desktop-v') | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| verify_app() { | |
| codesign --verify --deep --strict --verbose=2 "$1" | |
| spctl --assess --type execute --verbose=2 "$1" | |
| } | |
| app_bundle="$(find apps/desktop/dist -maxdepth 2 -type d -name '*.app' -print -quit)" | |
| zip_path="$(find apps/desktop/dist -maxdepth 1 -type f -name '*-mac.zip' -print -quit)" | |
| dmg_path="$(find apps/desktop/dist -maxdepth 1 -type f -name '*.dmg' -print -quit)" | |
| if [ -z "$app_bundle" ] || [ -z "$zip_path" ] || [ -z "$dmg_path" ]; then | |
| echo 'Complete macOS release artifacts were not found' >&2 | |
| exit 1 | |
| fi | |
| verify_app "$app_bundle" | |
| zip_dir="${RUNNER_TEMP}/macos-zip" | |
| mkdir "$zip_dir" | |
| ditto -x -k "$zip_path" "$zip_dir" | |
| zip_app="$(find "$zip_dir" -maxdepth 2 -type d -name '*.app' -print -quit)" | |
| if [ -z "$zip_app" ]; then | |
| echo 'macOS ZIP does not contain an application bundle' >&2 | |
| exit 1 | |
| fi | |
| verify_app "$zip_app" | |
| hdiutil verify "$dmg_path" | |
| mount_point="${RUNNER_TEMP}/macos-dmg" | |
| mkdir "$mount_point" | |
| hdiutil attach "$dmg_path" -readonly -nobrowse -mountpoint "$mount_point" | |
| cleanup_mount() { hdiutil detach "$mount_point" || true; } | |
| trap cleanup_mount EXIT | |
| dmg_app="$(find "$mount_point" -maxdepth 2 -type d -name '*.app' -print -quit)" | |
| if [ -z "$dmg_app" ]; then | |
| echo 'macOS DMG does not contain an application bundle' >&2 | |
| exit 1 | |
| fi | |
| verify_app "$dmg_app" | |
| xcrun stapler validate "$dmg_path" | |
| cleanup_mount | |
| trap - EXIT | |
| - name: Mint releases-repo token | |
| if: startsWith(github.ref, 'refs/tags/desktop-v') | |
| id: releases_token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # pinned from v3.2.0 | |
| with: | |
| app-id: ${{ secrets.DESKTOP_RELEASES_APP_ID }} | |
| private-key: ${{ secrets.DESKTOP_RELEASES_APP_PRIVATE_KEY }} | |
| owner: PyModel | |
| repositories: pythinker-desktop-releases | |
| permission-contents: write | |
| - name: Upload verified macOS release assets | |
| if: startsWith(github.ref, 'refs/tags/desktop-v') | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ steps.releases_token.outputs.token }} | |
| RELEASE_TAG: ${{ needs.prepare.outputs.tag }} | |
| RELEASE_REPO: PyModel/pythinker-desktop-releases | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| assets=(apps/desktop/dist/*.dmg apps/desktop/dist/*-mac.zip apps/desktop/dist/*.blockmap apps/desktop/dist/latest-mac.yml) | |
| gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "$RELEASE_REPO" --clobber | |
| - name: Upload macOS artifacts for manual runs | |
| if: github.event_name == 'workflow_dispatch' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7 | |
| with: | |
| name: desktop-macos | |
| path: | | |
| apps/desktop/dist/*.dmg | |
| apps/desktop/dist/*.zip | |
| apps/desktop/dist/latest-mac.yml | |
| if-no-files-found: error | |
| windows: | |
| needs: prepare | |
| runs-on: windows-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6 | |
| - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6 | |
| with: | |
| node-version-file: .nvmrc | |
| cache: pnpm | |
| - run: pnpm install --frozen-lockfile | |
| - name: Build workspace | |
| run: pnpm --workspace-root run build | |
| - name: Stage desktop runtime | |
| working-directory: apps/desktop | |
| run: node --import tsx scripts/stage-runtime.ts | |
| # Only non-empty WIN_CSC_* signing secrets are exported. Without them, | |
| # Windows artifacts are unsigned and installers trigger a SmartScreen | |
| # warning on first run. | |
| - name: Resolve Windows signing credentials | |
| shell: bash | |
| env: | |
| IN_WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} | |
| IN_WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} | |
| IN_WINDOWS_SIGNING_PUBLISHER_NAME: ${{ secrets.WINDOWS_SIGNING_PUBLISHER_NAME }} | |
| run: | | |
| delimiter="EOF_$(openssl rand -hex 16)" | |
| for name in WIN_CSC_LINK WIN_CSC_KEY_PASSWORD WINDOWS_SIGNING_PUBLISHER_NAME; do | |
| input="IN_${name}" | |
| value="${!input:-}" | |
| if [ -n "$value" ]; then printf '%s<<%s\n%s\n%s\n' "$name" "$delimiter" "$value" "$delimiter" >> "$GITHUB_ENV"; fi | |
| done | |
| - name: Resolve Azure signing configuration | |
| shell: bash | |
| env: | |
| IN_AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} | |
| IN_AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} | |
| IN_AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} | |
| IN_AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} | |
| IN_AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} | |
| IN_AZURE_SIGNING_CERT_PROFILE: ${{ secrets.AZURE_SIGNING_CERT_PROFILE }} | |
| IN_AZURE_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_SIGNING_PUBLISHER_NAME }} | |
| run: | | |
| delimiter="EOF_$(openssl rand -hex 16)" | |
| for name in AZURE_TENANT_ID AZURE_CLIENT_ID AZURE_CLIENT_SECRET AZURE_SIGNING_ENDPOINT AZURE_SIGNING_ACCOUNT AZURE_SIGNING_CERT_PROFILE AZURE_SIGNING_PUBLISHER_NAME; do | |
| input="IN_${name}" | |
| value="${!input:-}" | |
| if [ -n "$value" ]; then printf '%s<<%s\n%s\n%s\n' "$name" "$delimiter" "$value" "$delimiter" >> "$GITHUB_ENV"; fi | |
| done | |
| - name: Require signing for tagged Windows releases | |
| if: startsWith(github.ref, 'refs/tags/desktop-v') | |
| working-directory: apps/desktop | |
| run: node --import tsx scripts/assert-windows-release-signing.ts | |
| - name: Package desktop release | |
| working-directory: apps/desktop | |
| run: node --import tsx scripts/package-win.ts --publish never | |
| - name: Verify Windows update artifacts | |
| shell: bash | |
| env: | |
| DESKTOP_VERSION: ${{ needs.prepare.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| test -f apps/desktop/dist/win-unpacked/resources/app-update.yml | |
| node --import tsx apps/desktop/scripts/verify-update-manifest.ts win apps/desktop/dist "$DESKTOP_VERSION" | |
| - name: Verify Windows release signatures | |
| if: startsWith(github.ref, 'refs/tags/desktop-v') | |
| working-directory: apps/desktop | |
| run: node --import tsx scripts/verify-windows-signatures.ts | |
| - name: Mint releases-repo token | |
| if: startsWith(github.ref, 'refs/tags/desktop-v') | |
| id: releases_token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # pinned from v3.2.0 | |
| with: | |
| app-id: ${{ secrets.DESKTOP_RELEASES_APP_ID }} | |
| private-key: ${{ secrets.DESKTOP_RELEASES_APP_PRIVATE_KEY }} | |
| owner: PyModel | |
| repositories: pythinker-desktop-releases | |
| permission-contents: write | |
| - name: Upload verified Windows release assets | |
| if: startsWith(github.ref, 'refs/tags/desktop-v') | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ steps.releases_token.outputs.token }} | |
| RELEASE_TAG: ${{ needs.prepare.outputs.tag }} | |
| RELEASE_REPO: PyModel/pythinker-desktop-releases | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| assets=(apps/desktop/dist/*.exe apps/desktop/dist/*.blockmap apps/desktop/dist/latest.yml) | |
| gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "$RELEASE_REPO" --clobber | |
| - name: Upload Windows artifacts for manual runs | |
| if: github.event_name == 'workflow_dispatch' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7 | |
| with: | |
| name: desktop-windows | |
| path: | | |
| apps/desktop/dist/*.exe | |
| apps/desktop/dist/latest.yml | |
| if-no-files-found: error | |
| # Publishing last, and only once both platforms uploaded, is what makes the | |
| # release atomic. A platform that fails leaves a draft nobody can download, | |
| # which is recoverable by re-running that job; publishing per-platform instead | |
| # would leave a live release missing an operating system. | |
| publish: | |
| if: startsWith(github.ref, 'refs/tags/desktop-v') | |
| needs: [prepare, mac, windows] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4 | |
| with: | |
| persist-credentials: false | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6 | |
| - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6 | |
| with: | |
| node-version-file: .nvmrc | |
| cache: pnpm | |
| - run: pnpm install --frozen-lockfile | |
| - name: Mint releases-repo token | |
| id: releases_token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # pinned from v3.2.0 | |
| with: | |
| app-id: ${{ secrets.DESKTOP_RELEASES_APP_ID }} | |
| private-key: ${{ secrets.DESKTOP_RELEASES_APP_PRIVATE_KEY }} | |
| owner: PyModel | |
| repositories: pythinker-desktop-releases | |
| permission-contents: write | |
| - name: Download and validate every release asset | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ steps.releases_token.outputs.token }} | |
| RELEASE_TAG: ${{ needs.prepare.outputs.tag }} | |
| RELEASE_REPO: PyModel/pythinker-desktop-releases | |
| DESKTOP_VERSION: ${{ needs.prepare.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| mkdir release-assets | |
| gh release download "$RELEASE_TAG" --repo "$RELEASE_REPO" --dir release-assets | |
| node --import tsx apps/desktop/scripts/verify-update-manifest.ts mac release-assets "$DESKTOP_VERSION" | |
| node --import tsx apps/desktop/scripts/verify-update-manifest.ts win release-assets "$DESKTOP_VERSION" | |
| - name: Publish the release | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ steps.releases_token.outputs.token }} | |
| RELEASE_TAG: ${{ needs.prepare.outputs.tag }} | |
| RELEASE_REPO: PyModel/pythinker-desktop-releases | |
| run: | | |
| set -euo pipefail | |
| gh release edit "$RELEASE_TAG" --repo "$RELEASE_REPO" --draft=false | |
| echo "Published ${RELEASE_TAG}." |