chore(desktop): bump the desktop app to 0.1.5 (#128) #7
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: write | |
| concurrency: | |
| group: desktop-release-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| # electron-builder's `getOrCreateRelease` lists the releases, and creates one | |
| # when no tag matches. That is a check-then-act with no lock, so two platform | |
| # jobs racing it both decide to create: one wins and the other gets | |
| # `422 Published releases must have a valid tag`. Creating the release here, | |
| # once, removes the race — every platform job then finds it and reuses it. | |
| # | |
| # It is created as a draft on purpose. A draft needs no valid git tag, so this | |
| # step cannot hit the same 422, and `getOrCreateRelease` returns an existing | |
| # draft before it consults either the release type or the two-hour rule. | |
| 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 | |
| - 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 | |
| # A tag build is authoritative; a manual run falls back to whatever | |
| # version the checked-out tree declares. | |
| if [ "$IS_TAG" = 'true' ]; then | |
| version="${TAG_NAME#desktop-v}" | |
| else | |
| version="$(node -p 'require("./apps/desktop/package.json").version')" | |
| fi | |
| if [ -z "$version" ]; then | |
| echo 'Could not resolve a desktop version' >&2 | |
| exit 1 | |
| 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}." | |
| - 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 | |
| - name: Create the draft release unless it already exists | |
| 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 | |
| # Re-runs and re-tags must not disturb a release that already exists, | |
| # published or not — this only ever adds a missing draft. | |
| if gh release view "$RELEASE_TAG" --repo "$RELEASE_REPO" >/dev/null 2>&1; then | |
| echo "Release ${RELEASE_TAG} already exists; leaving it as it is." | |
| 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: true | |
| - 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: Stamp desktop version for tag builds | |
| if: startsWith(github.ref, 'refs/tags/desktop-v') | |
| env: | |
| TAG_NAME: ${{ github.ref_name }} | |
| run: | | |
| export DESKTOP_VERSION="${TAG_NAME#desktop-v}" | |
| node -e 'const fs = require("node:fs"); const path = "apps/desktop/package.json"; const packageJson = JSON.parse(fs.readFileSync(path, "utf8")); packageJson.version = process.env.DESKTOP_VERSION; fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);' | |
| - 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 | |
| # On a desktop-v* tag, --publish always creates or updates the release | |
| # in pythinker-desktop-releases with the GitHub App token below. | |
| # Without Developer ID signing secrets, electron-builder publishes an | |
| # ad-hoc/self-signed app. macOS auto-update will not accept unsigned updates, | |
| # but this still proves packaging and the feed shape. | |
| # 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: 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 | |
| - name: Package and publish desktop release | |
| working-directory: apps/desktop | |
| run: pnpm exec electron-builder --mac dmg zip --publish always | |
| env: | |
| GH_TOKEN: ${{ steps.releases_token.outputs.token }} | |
| # Without this, electron-builder refuses to upload to a release that | |
| # was published more than two hours ago — and it does so by logging | |
| # "skipped publishing" and exiting 0, so a re-run would go green | |
| # having shipped nothing. | |
| EP_GH_IGNORE_TIME: 'true' | |
| - name: Verify macOS packaged update configuration | |
| shell: bash | |
| run: | | |
| 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" | |
| - 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: true | |
| - 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: Stamp desktop version for tag builds | |
| if: startsWith(github.ref, 'refs/tags/desktop-v') | |
| shell: bash | |
| env: | |
| TAG_NAME: ${{ github.ref_name }} | |
| run: | | |
| export DESKTOP_VERSION="${TAG_NAME#desktop-v}" | |
| node -e 'const fs = require("node:fs"); const path = "apps/desktop/package.json"; const packageJson = JSON.parse(fs.readFileSync(path, "utf8")); packageJson.version = process.env.DESKTOP_VERSION; fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);' | |
| - 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 }} | |
| run: | | |
| for name in WIN_CSC_LINK WIN_CSC_KEY_PASSWORD; do | |
| input="IN_${name}" | |
| value="${!input:-}" | |
| if [ -n "$value" ]; then printf '%s<<__EOF__\n%s\n__EOF__\n' "$name" "$value" >> "$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: | | |
| 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<<__EOF__\n%s\n__EOF__\n' "$name" "$value" >> "$GITHUB_ENV"; fi | |
| done | |
| - 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 | |
| - name: Package and publish desktop release | |
| working-directory: apps/desktop | |
| run: node --import tsx scripts/package-win.ts --publish always | |
| env: | |
| GH_TOKEN: ${{ steps.releases_token.outputs.token }} | |
| # See the macOS job: without this a re-run against an older release | |
| # silently uploads nothing and still reports success. | |
| EP_GH_IGNORE_TIME: 'true' | |
| - name: Verify Windows packaged update configuration | |
| shell: bash | |
| run: test -f apps/desktop/dist/win-unpacked/resources/app-update.yml | |
| - 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: | |
| needs: [prepare, mac, windows] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - 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 | |
| - name: Require every expected artifact before publishing | |
| 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 view "$RELEASE_TAG" --repo "$RELEASE_REPO" \ | |
| --json assets --jq '.assets[].name' > assets.txt | |
| echo 'Assets on the release:' | |
| sed 's/^/ /' assets.txt | |
| # Counting assets is not enough: the half-release that shipped before | |
| # had four of them and no .dmg. Each artifact is named individually. | |
| missing=0 | |
| require() { | |
| if ! grep -qE "$1" assets.txt; then | |
| echo "Missing artifact: $2" >&2 | |
| missing=1 | |
| fi | |
| } | |
| require '\.dmg$' 'macOS disk image' | |
| require '\-mac\.zip$' 'macOS update archive' | |
| require '^latest-mac\.yml$' 'macOS update manifest' | |
| require '\-Setup\.exe$' 'Windows installer' | |
| require '^latest\.yml$' 'Windows update manifest' | |
| if [ "$missing" -ne 0 ]; then | |
| echo 'Leaving the release as a draft.' >&2 | |
| exit 1 | |
| fi | |
| echo 'All expected artifacts are present.' | |
| - 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}." |