|
69 | 69 | echo "ℹ️ Not a release commit" |
70 | 70 | fi |
71 | 71 |
|
| 72 | + # Detect shell-code changes on dev/staging pushes. Web-only changes never |
| 73 | + # need a desktop build (installed shells load the web app live); changes to |
| 74 | + # the Electron app or the bridge packages trigger a per-env prerelease build |
| 75 | + # (dev → alpha channel, staging → beta) that the env's update feed |
| 76 | + # (/api/desktop/update) starts offering automatically. |
| 77 | + detect-desktop-changes: |
| 78 | + name: Detect Desktop Changes |
| 79 | + runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 80 | + timeout-minutes: 5 |
| 81 | + if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/staging') |
| 82 | + outputs: |
| 83 | + changed: ${{ steps.diff.outputs.changed }} |
| 84 | + steps: |
| 85 | + - name: Checkout code |
| 86 | + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 |
| 87 | + with: |
| 88 | + fetch-depth: 50 |
| 89 | + |
| 90 | + - name: Diff desktop paths |
| 91 | + id: diff |
| 92 | + env: |
| 93 | + BEFORE: ${{ github.event.before }} |
| 94 | + run: | |
| 95 | + # Force pushes (dev resets) can reference a BEFORE we don't have; |
| 96 | + # fall back to the previous commit, and to no build when even that |
| 97 | + # is unavailable. |
| 98 | + if [ -z "$BEFORE" ] || ! git cat-file -e "$BEFORE" 2>/dev/null; then |
| 99 | + BEFORE="$(git rev-parse HEAD^ 2>/dev/null || echo '')" |
| 100 | + fi |
| 101 | + if [ -z "$BEFORE" ]; then |
| 102 | + echo "changed=false" >> "$GITHUB_OUTPUT" |
| 103 | + echo "ℹ️ No comparable base commit; skipping desktop prerelease" |
| 104 | + exit 0 |
| 105 | + fi |
| 106 | + if git diff --name-only "$BEFORE" HEAD | grep -qE '^(apps/desktop/|packages/desktop-bridge/|packages/browser-protocol/)'; then |
| 107 | + echo "changed=true" >> "$GITHUB_OUTPUT" |
| 108 | + echo "✅ Desktop shell code changed" |
| 109 | + else |
| 110 | + echo "changed=false" >> "$GITHUB_OUTPUT" |
| 111 | + echo "ℹ️ No desktop shell changes" |
| 112 | + fi |
| 113 | +
|
72 | 114 | # Run database migrations before images are promoted: the ECR latest/staging |
73 | 115 | # tag push triggers CodePipeline, so migrating first guarantees the schema is |
74 | 116 | # in place before the new app version deploys (replaces the removed ECS |
@@ -590,3 +632,172 @@ jobs: |
590 | 632 | env: |
591 | 633 | GH_PAT: ${{ secrets.GITHUB_TOKEN }} |
592 | 634 | run: bun run scripts/create-single-release.ts ${{ needs.detect-version.outputs.version }} |
| 635 | + |
| 636 | + # Desktop release: builds, signs, notarizes, and attaches the macOS app to |
| 637 | + # the GitHub release created above. Gated on the Apple signing secrets so a |
| 638 | + # release pipeline run skips cleanly (instead of failing) until the Apple |
| 639 | + # Developer account is provisioned — the moment the six secrets exist, the |
| 640 | + # next vX.Y.Z release ships desktop artifacts with no further changes. |
| 641 | + # Job-level `if:` cannot read the secrets context, hence the probe job. |
| 642 | + check-desktop-signing: |
| 643 | + name: Check Desktop Signing Secrets |
| 644 | + runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 645 | + timeout-minutes: 2 |
| 646 | + needs: [detect-version, detect-desktop-changes] |
| 647 | + # !cancelled(): detect-desktop-changes is skipped on main (and |
| 648 | + # detect-version tags only on main); either path may need the probe. |
| 649 | + if: ${{ !cancelled() && (needs.detect-version.outputs.is_release == 'true' || needs.detect-desktop-changes.outputs.changed == 'true') }} |
| 650 | + outputs: |
| 651 | + configured: ${{ steps.check.outputs.configured }} |
| 652 | + steps: |
| 653 | + - name: Probe Apple signing secrets |
| 654 | + id: check |
| 655 | + env: |
| 656 | + CONFIGURED: ${{ secrets.CSC_LINK != '' && secrets.CSC_KEY_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER != '' && secrets.APPLE_TEAM_ID != '' }} |
| 657 | + run: | |
| 658 | + echo "configured=${CONFIGURED}" >> "$GITHUB_OUTPUT" |
| 659 | + if [ "$CONFIGURED" != "true" ]; then |
| 660 | + echo "::warning::Desktop release skipped: Apple signing secrets are not configured (CSC_LINK, CSC_KEY_PASSWORD, APPLE_API_KEY_P8, APPLE_API_KEY_ID, APPLE_API_ISSUER, APPLE_TEAM_ID)." |
| 661 | + fi |
| 662 | +
|
| 663 | + desktop-release: |
| 664 | + name: Desktop Release |
| 665 | + needs: [create-release, check-desktop-signing, detect-version] |
| 666 | + if: needs.check-desktop-signing.outputs.configured == 'true' |
| 667 | + permissions: |
| 668 | + contents: write |
| 669 | + uses: ./.github/workflows/desktop-release.yml |
| 670 | + with: |
| 671 | + version: ${{ needs.detect-version.outputs.version }} |
| 672 | + publish: true |
| 673 | + secrets: inherit |
| 674 | + |
| 675 | + # Per-env desktop prereleases: a dev/staging push that touches shell code |
| 676 | + # publishes a channel-tagged GitHub prerelease (vX.Y.Z-alpha.N from dev, |
| 677 | + # vX.Y.Z-beta.N from staging). Each environment's /api/desktop/update feed |
| 678 | + # offers only its channel, so dev-pointed shells pick up alpha builds, |
| 679 | + # staging-pointed shells beta builds, and prod-pointed shells stable |
| 680 | + # releases — independently. Unlike stable releases, prereleases build even |
| 681 | + # before the Apple signing secrets exist — unsigned, so the update pipeline |
| 682 | + # is testable end to end; installed shells detect the missing Developer ID |
| 683 | + # and offer a manual download instead of a Squirrel install. |
| 684 | + create-desktop-prerelease: |
| 685 | + name: Create Desktop Prerelease |
| 686 | + runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 687 | + timeout-minutes: 5 |
| 688 | + needs: [detect-desktop-changes, check-desktop-signing] |
| 689 | + # Requires the signing probe to have actually succeeded (not just "not |
| 690 | + # cancelled") so a probe failure can't produce a release with no build. |
| 691 | + if: ${{ !cancelled() && needs.detect-desktop-changes.outputs.changed == 'true' && needs.check-desktop-signing.result == 'success' }} |
| 692 | + permissions: |
| 693 | + contents: write |
| 694 | + outputs: |
| 695 | + version: ${{ steps.version.outputs.version }} |
| 696 | + steps: |
| 697 | + - name: Checkout code |
| 698 | + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 |
| 699 | + |
| 700 | + - name: Compute prerelease version and create draft release |
| 701 | + id: version |
| 702 | + env: |
| 703 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 704 | + GH_REPO: ${{ github.repository }} |
| 705 | + SIGNED: ${{ needs.check-desktop-signing.outputs.configured }} |
| 706 | + run: | |
| 707 | + if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; APP_NAME="Sim Dev"; else CHANNEL=beta; APP_NAME="Sim Staging"; fi |
| 708 | + # Prerelease core = next patch after the latest stable release, so |
| 709 | + # channel builds always outrank the stable they are built on top of |
| 710 | + # and are always superseded by the next stable. The run-attempt |
| 711 | + # suffix keeps re-runs of the same workflow from colliding on the |
| 712 | + # tag while preserving semver ordering. |
| 713 | + LATEST="$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName' || true)" |
| 714 | + LATEST="${LATEST:-v0.0.0}" |
| 715 | + IFS='.' read -r MAJOR MINOR PATCH <<< "${LATEST#v}" |
| 716 | + TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))-${CHANNEL}.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" |
| 717 | + NOTES="Automated ${CHANNEL}-channel desktop build from ${GITHUB_REF_NAME} @ ${GITHUB_SHA::7}." |
| 718 | + if [ "$SIGNED" != "true" ]; then |
| 719 | + NOTES="$NOTES |
| 720 | +
|
| 721 | + ⚠️ Unsigned test build (Apple signing secrets not configured). Gatekeeper will quarantine a downloaded copy: right-click → Open, or clear the flag with \`xattr -dr com.apple.quarantine \"/Applications/${APP_NAME}.app\"\`." |
| 722 | + fi |
| 723 | + # Draft until the build uploads its artifacts: drafts are invisible |
| 724 | + # to the update feed, so a failed or in-flight build can never take |
| 725 | + # the channel down with an assetless release. Publishing later also |
| 726 | + # defers tag creation, so failed builds strand no tags. |
| 727 | + gh release create "$TAG" \ |
| 728 | + --draft \ |
| 729 | + --prerelease \ |
| 730 | + --target "$GITHUB_SHA" \ |
| 731 | + --title "$TAG" \ |
| 732 | + --notes "$NOTES" |
| 733 | + echo "version=$TAG" >> "$GITHUB_OUTPUT" |
| 734 | + echo "✅ Created draft prerelease $TAG" |
| 735 | +
|
| 736 | + desktop-prerelease: |
| 737 | + name: Desktop Prerelease Build |
| 738 | + needs: [create-desktop-prerelease, check-desktop-signing] |
| 739 | + permissions: |
| 740 | + contents: write |
| 741 | + uses: ./.github/workflows/desktop-release.yml |
| 742 | + with: |
| 743 | + version: ${{ needs.create-desktop-prerelease.outputs.version }} |
| 744 | + publish: true |
| 745 | + sign: ${{ needs.check-desktop-signing.outputs.configured == 'true' }} |
| 746 | + secrets: inherit |
| 747 | + |
| 748 | + # The draft only becomes visible to the update feed once its artifacts are |
| 749 | + # attached — this is what makes a dev/staging push atomic from the shell's |
| 750 | + # point of view. |
| 751 | + publish-desktop-prerelease: |
| 752 | + name: Publish Desktop Prerelease |
| 753 | + runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 754 | + timeout-minutes: 5 |
| 755 | + needs: [create-desktop-prerelease, desktop-prerelease] |
| 756 | + permissions: |
| 757 | + contents: write |
| 758 | + env: |
| 759 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 760 | + GH_REPO: ${{ github.repository }} |
| 761 | + TAG: ${{ needs.create-desktop-prerelease.outputs.version }} |
| 762 | + steps: |
| 763 | + - name: Publish the draft release |
| 764 | + run: gh release edit "$TAG" --draft=false |
| 765 | + |
| 766 | + # Keep the release list tidy: per channel, retain the newest 5 prereleases |
| 767 | + # and delete the rest (with their tags, so dev force-resets don't strand |
| 768 | + # commits behind stale tags). Leftover drafts (failed or superseded builds) |
| 769 | + # are always garbage by this point — the current run's release is published. |
| 770 | + prune-desktop-prereleases: |
| 771 | + name: Prune Desktop Prereleases |
| 772 | + runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 773 | + timeout-minutes: 5 |
| 774 | + needs: [publish-desktop-prerelease] |
| 775 | + permissions: |
| 776 | + contents: write |
| 777 | + env: |
| 778 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 779 | + GH_REPO: ${{ github.repository }} |
| 780 | + steps: |
| 781 | + - name: Delete stale prereleases |
| 782 | + run: | |
| 783 | + if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; else CHANNEL=beta; fi |
| 784 | + gh release list --limit 100 --json tagName,isPrerelease,isDraft,createdAt \ |
| 785 | + --jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNEL}\\\\.\")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName" | |
| 786 | + while read -r TAG; do |
| 787 | + [ -n "$TAG" ] || continue |
| 788 | + echo "Deleting stale prerelease $TAG" |
| 789 | + gh release delete "$TAG" --cleanup-tag --yes |
| 790 | + done |
| 791 | +
|
| 792 | + - name: Delete leftover draft prereleases |
| 793 | + run: | |
| 794 | + if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; else CHANNEL=beta; fi |
| 795 | + # Drafts have no tag ref, so delete by release id via the API |
| 796 | + # (gh release delete resolves by tag, which is ambiguous for drafts). |
| 797 | + gh api "repos/${GH_REPO}/releases?per_page=100" \ |
| 798 | + --jq ".[] | select(.draft and (.tag_name | test(\"-${CHANNEL}\\\\.\"))) | .id" | |
| 799 | + while read -r ID; do |
| 800 | + [ -n "$ID" ] || continue |
| 801 | + echo "Deleting leftover draft release $ID" |
| 802 | + gh api -X DELETE "repos/${GH_REPO}/releases/${ID}" |
| 803 | + done |
0 commit comments