fix(desktop): complete the update restart flow (#126) #154
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: Release | |
| on: | |
| push: | |
| branches: | |
| - main | |
| concurrency: ${{ github.workflow }}-${{ github.ref }} | |
| jobs: | |
| # Publishing to npm is irreversible — never let a commit that fails CI or | |
| # Nix Build ship. Those workflows run in parallel on the same push, so this | |
| # job blocks the release until both conclude successfully for this SHA. | |
| wait-for-checks: | |
| name: Wait for CI and Nix Build | |
| runs-on: ubuntu-latest | |
| if: github.repository_owner == 'PyModel' | |
| timeout-minutes: 45 | |
| permissions: | |
| actions: read | |
| contents: read | |
| steps: | |
| - name: Wait for required workflows on this commit | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| SHA: ${{ github.sha }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| for workflow in "CI" "Nix Build"; do | |
| echo "Waiting for workflow: $workflow" | |
| while true; do | |
| # Transient API failures must not kill the gate (step shell is -e). | |
| pair=$(gh api "repos/$REPO/actions/runs?head_sha=$SHA&per_page=50" \ | |
| --jq "[.workflow_runs[] | select(.name==\"$workflow\")][0] | \"\(.status)/\(.conclusion)\"" \ | |
| 2>/dev/null) || pair="api-error/null" | |
| case "$pair" in | |
| completed/success) | |
| echo "$workflow: success"; break ;; | |
| completed/*) | |
| echo "::error::$workflow concluded '${pair#completed/}' for $SHA — refusing to release." | |
| exit 1 ;; | |
| *) | |
| echo "$workflow: $pair — waiting..."; sleep 30 ;; | |
| esac | |
| done | |
| done | |
| release: | |
| name: Release | |
| needs: wait-for-checks | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| if: github.repository_owner == 'PyModel' | |
| outputs: | |
| packages_published: ${{ steps.changesets.outputs.published }} | |
| pythinker_native_release: ${{ steps.pythinker-release.outputs.should_publish }} | |
| pythinker_release_tag: ${{ steps.pythinker-release.outputs.tag }} | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| id-token: write # Required for NPM Trusted Publishing (OIDC) | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6 | |
| with: | |
| node-version-file: .nvmrc | |
| cache: "pnpm" | |
| registry-url: "https://registry.npmjs.org" | |
| - name: Upgrade npm for Trusted Publishing | |
| run: npm install -g npm@latest | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Generate Pythinker Code built-in catalog | |
| shell: bash | |
| run: | | |
| CATALOG_FILE="$RUNNER_TEMP/pythinker-code-built-in-catalog.json" | |
| node apps/pythinker-code/scripts/update-catalog.mjs --out "$CATALOG_FILE" | |
| echo "PYTHINKER_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV" | |
| - name: Build packages | |
| run: pnpm build | |
| - name: Mint release-bot token | |
| id: release-bot | |
| uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from v2 | |
| with: | |
| app-id: ${{ vars.RELEASE_BOT_APP_ID }} | |
| private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} | |
| - name: Create Release Pull Request or Publish to npm | |
| id: changesets | |
| uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # pinned from v1 | |
| with: | |
| publish: node scripts/release/changeset-publish-idempotent.mjs | |
| version: pnpm run version:release | |
| commit: "ci: release packages" | |
| title: "ci: release packages" | |
| env: | |
| # App token (not GITHUB_TOKEN) so the version PR triggers pull_request | |
| # workflows and required status checks / CodeRabbit run on it. | |
| GITHUB_TOKEN: ${{ steps.release-bot.outputs.token }} | |
| # No NPM_TOKEN on purpose: changesets prefers it over OIDC when set, so | |
| # defining it would silently downgrade publishing to a long-lived token. | |
| - name: Request CodeRabbit review on version PR | |
| if: steps.changesets.outputs.published != 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.release-bot.outputs.token }} | |
| run: | | |
| pr=$(gh pr list --head changeset-release/main --state open --json number --jq '.[0].number' || true) | |
| if [ -n "$pr" ] && [ "$pr" != "null" ]; then | |
| gh pr comment "$pr" --body '@coderabbitai review' | |
| fi | |
| - name: Resolve Pythinker Code native release | |
| if: steps.changesets.outputs.published == 'true' | |
| id: pythinker-release | |
| run: node apps/pythinker-code/scripts/native/resolve-release.mjs | |
| env: | |
| CHANGESETS_PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }} | |
| # The VS Code extension is a private workspace package: changesets bumps its | |
| # version but never publishes it to npm, so it ships from here instead. Both | |
| # publish scripts skip packages that already exist in the registry, so this | |
| # job is a no-op on releases that did not touch the extension. | |
| publish-vscode-extension: | |
| timeout-minutes: 45 | |
| name: Publish VS Code extension | |
| needs: release | |
| if: needs.release.outputs.packages_published == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6 | |
| with: | |
| node-version-file: .nvmrc | |
| cache: "pnpm" | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Generate Pythinker Code built-in catalog | |
| shell: bash | |
| run: | | |
| CATALOG_FILE="$RUNNER_TEMP/pythinker-code-built-in-catalog.json" | |
| node apps/pythinker-code/scripts/update-catalog.mjs --out "$CATALOG_FILE" | |
| echo "PYTHINKER_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV" | |
| - name: Build workspace packages | |
| run: pnpm build | |
| # Packages all six platform targets and runs the VSIX audit on each one. | |
| # A failure here must stop the job before anything reaches a registry. | |
| - name: Package and verify VSIX targets | |
| run: pnpm --filter pythinker run package:platform | |
| - name: Publish to the Visual Studio Marketplace | |
| env: | |
| VSCE_PAT: ${{ secrets.VSCE_PAT }} | |
| run: | | |
| if [ -z "$VSCE_PAT" ]; then | |
| echo "::warning::VSCE_PAT secret not set — skipping Marketplace publish." | |
| exit 0 | |
| fi | |
| pnpm --filter pythinker run publish:vsix | |
| # Open VSX serves Cursor / VSCodium / Windsurf. A failure here must not | |
| # undo an already-successful Marketplace publish, so it only warns. | |
| - name: Publish to Open VSX | |
| continue-on-error: true | |
| env: | |
| OVSX_PAT: ${{ secrets.OVSX_PAT }} | |
| run: | | |
| if [ -z "$OVSX_PAT" ]; then | |
| echo "::warning::OVSX_PAT secret not set — skipping Open VSX publish." | |
| exit 0 | |
| fi | |
| pnpm --filter pythinker run publish:ovsx | |
| - name: Upload VSIX artifacts | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7 | |
| with: | |
| name: pythinker-code-vsix | |
| path: apps/vscode/artifacts/vsix/*.vsix | |
| retention-days: 7 | |
| if-no-files-found: error | |
| # code.pythinker.com redeploys via Dokploy autodeploy on push to main (app | |
| # Pythinker/code builds apps/site/Dockerfile from the repo). That autodeploy | |
| # fires on the `ci: release packages` push — which STARTS the release — while | |
| # apps/site/scripts/build-cdn.mjs reads the version from npm's dist-tag, which | |
| # only moves when the publish FINISHES. The first build therefore bakes in the | |
| # previous version and nothing rebuilds it, so the release stays invisible to | |
| # every installed client. This job fires a second deploy after the publish. | |
| # | |
| # It must run after publish-native-assets: latest.json only gets its | |
| # per-platform `platforms` block once the native zips exist on the release. | |
| # That job is itself conditional and SKIPS on an npm-only release, and a job | |
| # whose `needs` includes a skipped job is skipped too — hence `always()`, and | |
| # hence the explicit upstream result assertions it forces us to spell out. | |
| redeploy-cdn: | |
| timeout-minutes: 10 | |
| name: Redeploy CDN | |
| needs: | |
| - release | |
| - publish-native-assets | |
| if: >- | |
| always() | |
| && needs.release.result == 'success' | |
| && needs.publish-native-assets.result != 'failure' | |
| && needs.publish-native-assets.result != 'cancelled' | |
| && (needs.release.outputs.packages_published == 'true' | |
| || startsWith(github.event.head_commit.message, 'ci: release packages')) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Trigger Dokploy rebuild | |
| env: | |
| WEBHOOK: ${{ secrets.DOKPLOY_CDN_DEPLOY_WEBHOOK }} | |
| run: | | |
| if [ -z "$WEBHOOK" ]; then | |
| echo "::warning::DOKPLOY_CDN_DEPLOY_WEBHOOK not set — skipping CDN redeploy." | |
| exit 0 | |
| fi | |
| # The URL is itself the deploy credential, so never send it over a | |
| # scheme that puts it on the wire in cleartext. Warn rather than fail: | |
| # verify-cdn-release runs only if this job succeeds, and failing here | |
| # would drop the consistency gate instead of tripping it. | |
| case "$WEBHOOK" in | |
| https://*) ;; | |
| *) | |
| echo "::warning::DOKPLOY_CDN_DEPLOY_WEBHOOK is not an https:// URL — refusing to send the deploy credential in cleartext." | |
| exit 0 | |
| ;; | |
| esac | |
| # The webhook reads the branch from the body, but only when the | |
| # request also carries `X-GitHub-Event`: Dokploy's extractBranchName | |
| # returns null without that header, so the request answers | |
| # 301 {"message":"Branch Not Match"} and deploys nothing. | |
| # | |
| # 301 is not an error status, so `--fail` does not see it and curl | |
| # exits 0. Capture the status code and treat anything but 2xx as a | |
| # failed deploy. | |
| # | |
| # A transient failure must never fail the workflow. npm has already | |
| # published by now and that is irreversible, so dying here buys | |
| # nothing — an earlier version of this job was deleted because a | |
| # curl exit-28 timeout failed the 0.5.0 release. verify-cdn-release | |
| # polls the manifest and is the gate that fails loudly. | |
| # On 0.18.0, one connect consumed the full 60-second budget. A short | |
| # connect timeout turns the same wall-clock budget into more attempts | |
| # during an outage instead of waiting on connections never made. | |
| status=$(curl -sS -o /dev/stderr -w '%{http_code}' -X POST "$WEBHOOK" \ | |
| -H 'Content-Type: application/json' \ | |
| -H 'X-GitHub-Event: push' \ | |
| -d '{"ref":"refs/heads/main"}' \ | |
| --connect-timeout 15 --max-time 45 --retry 5 --retry-all-errors --retry-delay 15) || status=000 | |
| case "$status" in | |
| 2*) echo "CDN redeploy triggered (HTTP $status)." ;; | |
| *) echo "::warning::CDN redeploy webhook returned HTTP $status — verify-cdn-release will catch a stale CDN." ;; | |
| esac | |
| # Verifies that the published release is internally consistent and that the | |
| # CDN caught up with npm. It polls, so it must run after redeploy-cdn. | |
| # | |
| # It also runs on a `ci: release packages` merge that published nothing: that | |
| # commit bumps the version on main, so gating the check on a successful | |
| # publish hid the one case where the version and the published artifacts | |
| # diverge — and every client polled the CDN for a release that never existed. | |
| verify-cdn-release: | |
| timeout-minutes: 20 | |
| name: Verify release consistency | |
| needs: | |
| - release | |
| - redeploy-cdn | |
| if: >- | |
| needs.release.outputs.packages_published == 'true' | |
| || startsWith(github.event.head_commit.message, 'ci: release packages') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6 | |
| with: | |
| node-version-file: .nvmrc | |
| - name: Verify release consistency | |
| env: | |
| DOKPLOY_CDN_DEPLOY_WEBHOOK: ${{ secrets.DOKPLOY_CDN_DEPLOY_WEBHOOK }} | |
| run: node scripts/release/verify-release-consistency.mjs | |
| update-brew-tap: | |
| timeout-minutes: 15 | |
| name: Update Homebrew tap | |
| needs: release | |
| if: needs.release.outputs.packages_published == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6 | |
| with: | |
| node-version-file: .nvmrc | |
| # Any permission-* input switches the token from inheriting every | |
| # permission the App installation holds to exactly the ones listed here. | |
| # Cloning and pushing the tap needs contents and nothing else. | |
| - name: Mint tap token | |
| id: tap-token | |
| uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from v2 | |
| with: | |
| app-id: ${{ vars.RELEASE_BOT_APP_ID }} | |
| private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} | |
| owner: PyModel | |
| repositories: homebrew-tap | |
| permission-contents: write | |
| - name: Bump formula | |
| env: | |
| TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }} | |
| run: | | |
| # The token comes from the App installation, not a PAT. | |
| if [ -z "$TAP_GITHUB_TOKEN" ]; then | |
| echo "TAP_GITHUB_TOKEN secret not set — skipping tap update" >&2 | |
| exit 0 | |
| fi | |
| node scripts/release/update-brew-formula.mjs | |
| deploy-docs: | |
| name: Deploy docs | |
| needs: release | |
| if: needs.release.outputs.packages_published == 'true' | |
| uses: ./.github/workflows/docs-deploy.yml | |
| permissions: | |
| contents: read | |
| pages: write | |
| id-token: write | |
| native-artifacts: | |
| name: Native release artifact | |
| needs: release | |
| if: needs.release.outputs.pythinker_native_release == 'true' | |
| uses: ./.github/workflows/_native-build.yml | |
| with: | |
| upload-artifact-prefix: pythinker-code-native | |
| retention-days: 7 | |
| sign-macos: true | |
| secrets: | |
| APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} | |
| APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} | |
| APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} | |
| APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} | |
| APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} | |
| publish-native-assets: | |
| timeout-minutes: 15 | |
| name: Publish native release assets | |
| needs: | |
| - release | |
| - native-artifacts | |
| if: needs.release.outputs.pythinker_native_release == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 | |
| - name: Download native artifacts | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # pinned from v8 | |
| with: | |
| pattern: pythinker-code-native-* | |
| path: dist-native-release | |
| merge-multiple: true | |
| - name: Assert all native targets are present | |
| run: | | |
| missing=0 | |
| for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64 win32-arm64 win32-x64; do | |
| if ! ls dist-native-release/pythinker-code-"$target".zip >/dev/null 2>&1; then | |
| echo "::error::Missing native bundle for $target — refusing to publish a partial release." | |
| missing=1 | |
| fi | |
| done | |
| exit $missing | |
| - name: Produce manifest.json | |
| env: | |
| RELEASE_TAG: ${{ needs.release.outputs.pythinker_release_tag }} | |
| run: node apps/pythinker-code/scripts/native/produce-manifest.mjs dist-native-release "$RELEASE_TAG" | |
| - name: Upload assets to GitHub Release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ needs.release.outputs.pythinker_release_tag }} | |
| run: gh release upload "$RELEASE_TAG" dist-native-release/* --clobber |