diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index b43164da..0f84ea6c 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -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 @@ -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 @@ -109,6 +190,7 @@ jobs: if-no-files-found: error windows: + needs: prepare runs-on: windows-latest steps: - name: Checkout @@ -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 @@ -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}."