Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,83 @@ concurrency:
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
Expand Down Expand Up @@ -86,6 +162,11 @@ jobs:
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
Expand All @@ -109,6 +190,7 @@ jobs:
if-no-files-found: error

windows:
needs: prepare
runs-on: windows-latest
steps:
- name: Checkout
Expand Down Expand Up @@ -185,6 +267,9 @@ jobs:
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
Expand All @@ -199,3 +284,64 @@ jobs:
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}."
Loading