Skip to content

chore(desktop): bump the desktop app to 0.1.5 (#128) #7

chore(desktop): bump the desktop app to 0.1.5 (#128)

chore(desktop): bump the desktop app to 0.1.5 (#128) #7

Workflow file for this run

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}."