From 4853433ad767682c2b13762b5750ddf0962b91ca Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 29 Apr 2026 19:47:12 +0700 Subject: [PATCH] fix(release): publish desktop manifests atomically to close arch race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS publish matrix had each arch run electron-builder with --publish always, so each runner uploaded its own latest-mac.yml to the GitHub release the moment it finished. The slower arch clobbered the faster one, leaving a window (typically 1-3 minutes) where the live manifest contained only one arch's files[]. Apple Silicon clients polling during that window could install the x64 build, ending up under Rosetta. Also closed the related window where finalize-rollout stamped releaseDate and rolloutHours after the merged manifest was already public — auto-updater treats missing rollout fields as "admit", so 100% of stable users could grab a release before staged rollout kicked in. Now every build runs --publish never. Each platform job uploads its non-yml artifacts directly via gh release upload --clobber, and stages its manifest as an Actions artifact. The renamed finalize-rollout job downloads all manifest artifacts, merges mac arm64+x64, stamps rollout metadata, validates, and uploads every manifest in one pass. Public manifests are never visible in an unmerged or unstamped state. Refs #555. --- .github/workflows/desktop-release.yml | 206 ++++++++++++++------------ docs/RELEASE.md | 11 +- 2 files changed, 124 insertions(+), 93 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 792fbacf5..54b09feb1 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -164,17 +164,26 @@ jobs: PASEO_DESKTOP_SMOKE: "1" run: | set -euo pipefail - publish_mode="never" - build_args=(-- --publish "$publish_mode" --mac --${{ matrix.electron_arch }}) - if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then - publish_mode="always" - build_args=(-- --publish "$publish_mode" --mac --${{ matrix.electron_arch }}) - build_args+=("-c.publish.releaseType=$RELEASE_TYPE") - build_args+=("-c.publish.channel=$RELEASE_CHANNEL") - fi - + build_args=(-- --publish never --mac --${{ matrix.electron_arch }}) + build_args+=("-c.publish.releaseType=$RELEASE_TYPE") + build_args+=("-c.publish.channel=$RELEASE_CHANNEL") npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}" + - name: Upload desktop artifacts to release + if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + release_dir="${DESKTOP_PACKAGE_PATH}/release" + mapfile -d '' files < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) + if (( ${#files[@]} == 0 )); then + echo "::error::No release artifacts found in $release_dir" + exit 1 + fi + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" + - name: Upload manifest artifact if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' uses: actions/upload-artifact@v4 @@ -183,63 +192,6 @@ jobs: path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml retention-days: 1 - finalize-mac-manifest: - needs: [publish-macos] - if: ${{ needs.publish-macos.result == 'success' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }} - permissions: - contents: write - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - sparse-checkout: | - package.json - package-lock.json - scripts - ref: ${{ env.CHECKOUT_REF }} - - - name: Resolve release tag - shell: bash - run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: "npm" - cache-dependency-path: package-lock.json - registry-url: "https://npm.pkg.github.com" - scope: "@boudra" - - - name: Install JS dependencies - run: npm ci - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Download manifest artifacts - if: env.IS_SMOKE_TAG != 'true' - uses: actions/download-artifact@v4 - with: - pattern: mac-manifest-* - - - name: Merge manifests - if: env.IS_SMOKE_TAG != 'true' - shell: bash - run: | - set -euo pipefail - manifest_name="${RELEASE_CHANNEL}-mac.yml" - node scripts/merge-mac-manifest.mjs \ - "mac-manifest-arm64/${manifest_name}" \ - "mac-manifest-x64/${manifest_name}" \ - "${manifest_name}" - - - name: Upload merged manifest to release - if: env.IS_SMOKE_TAG != 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload "$RELEASE_TAG" "$RELEASE_CHANNEL-mac.yml" --clobber --repo "${{ github.repository }}" - publish-linux: needs: [create-release] if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }} @@ -302,17 +254,34 @@ jobs: PASEO_DESKTOP_SMOKE: "1" run: | set -euo pipefail - publish_mode="never" - build_args=(-- --publish "$publish_mode" --linux --x64) - if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then - publish_mode="always" - build_args=(-- --publish "$publish_mode" --linux --x64) - build_args+=("-c.publish.releaseType=$RELEASE_TYPE") - build_args+=("-c.publish.channel=$RELEASE_CHANNEL") - fi - + build_args=(-- --publish never --linux --x64) + build_args+=("-c.publish.releaseType=$RELEASE_TYPE") + build_args+=("-c.publish.channel=$RELEASE_CHANNEL") npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}" + - name: Upload desktop artifacts to release + if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + release_dir="${DESKTOP_PACKAGE_PATH}/release" + mapfile -d '' files < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) + if (( ${#files[@]} == 0 )); then + echo "::error::No release artifacts found in $release_dir" + exit 1 + fi + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" + + - name: Upload manifest artifact + if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' + uses: actions/upload-artifact@v4 + with: + name: linux-manifest + path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-linux.yml + retention-days: 1 + publish-windows: needs: [create-release] if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }} @@ -380,20 +349,37 @@ jobs: PASEO_DESKTOP_SMOKE: "1" run: | set -euo pipefail - publish_mode="never" - build_args=(-- --publish "$publish_mode" --win --x64) - if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then - publish_mode="always" - build_args=(-- --publish "$publish_mode" --win --x64) - build_args+=("-c.publish.releaseType=$RELEASE_TYPE") - build_args+=("-c.publish.channel=$RELEASE_CHANNEL") - fi - + build_args=(-- --publish never --win --x64) + build_args+=("-c.publish.releaseType=$RELEASE_TYPE") + build_args+=("-c.publish.channel=$RELEASE_CHANNEL") npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}" + - name: Upload desktop artifacts to release + if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + release_dir="${DESKTOP_PACKAGE_PATH}/release" + mapfile -d '' files < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) + if (( ${#files[@]} == 0 )); then + echo "::error::No release artifacts found in $release_dir" + exit 1 + fi + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" + + - name: Upload manifest artifact + if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' + uses: actions/upload-artifact@v4 + with: + name: windows-manifest + path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}.yml + retention-days: 1 + finalize-rollout: - needs: [publish-macos, finalize-mac-manifest, publish-linux, publish-windows] - if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.finalize-mac-manifest.result == 'success' || needs.finalize-mac-manifest.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }} + needs: [publish-macos, publish-linux, publish-windows] + if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }} permissions: contents: write runs-on: ubuntu-latest @@ -428,20 +414,58 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Stamp rollout metadata + - name: Download mac manifest artifacts + if: env.IS_SMOKE_TAG != 'true' && needs.publish-macos.result == 'success' + uses: actions/download-artifact@v4 + with: + pattern: mac-manifest-* + path: release-manifests + + - name: Download Linux manifest artifact + if: env.IS_SMOKE_TAG != 'true' && needs.publish-linux.result == 'success' + uses: actions/download-artifact@v4 + with: + name: linux-manifest + path: release-manifests/linux-manifest + + - name: Download Windows manifest artifact + if: env.IS_SMOKE_TAG != 'true' && needs.publish-windows.result == 'success' + uses: actions/download-artifact@v4 + with: + name: windows-manifest + path: release-manifests/windows-manifest + + - name: Assemble and upload stamped manifests if: env.IS_SMOKE_TAG != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail - mkdir release-manifests cd release-manifests - gh release download "$RELEASE_TAG" --repo "${{ github.repository }}" --pattern "${RELEASE_CHANNEL}*.yml" + manifests_dir="$PWD/final" + mkdir -p "$manifests_dir" + + if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then + manifest_name="${RELEASE_CHANNEL}-mac.yml" + node ../scripts/merge-mac-manifest.mjs \ + "mac-manifest-arm64/${manifest_name}" \ + "mac-manifest-x64/${manifest_name}" \ + "$manifests_dir/${manifest_name}" + fi + + if [[ "${{ needs.publish-linux.result }}" == "success" ]]; then + cp "linux-manifest/${RELEASE_CHANNEL}-linux.yml" "$manifests_dir/" + fi + + if [[ "${{ needs.publish-windows.result }}" == "success" ]]; then + cp "windows-manifest/${RELEASE_CHANNEL}.yml" "$manifests_dir/" + fi + shopt -s nullglob - files=( ./*.yml ) + files=( "$manifests_dir"/*.yml ) if (( ${#files[@]} == 0 )); then - echo "::error::No manifests matched ${RELEASE_CHANNEL}*.yml on $RELEASE_TAG" + echo "::error::No manifest artifacts were available to publish" exit 1 fi timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" diff --git a/docs/RELEASE.md b/docs/RELEASE.md index ce5af344b..19709d13a 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -61,10 +61,17 @@ Use the beta path when you need to: ## Staged rollout (stable channel) -Stable desktop releases go out via a linear time-based rollout: 0% admitted at publish, 100% admitted 24 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately. +Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 24 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately. The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`. +Desktop release builds now publish in two phases: + +- Platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.) to the GitHub release. +- The final job merges/stamps the manifests and uploads all `.yml` files only after they already contain the final `releaseDate` and `rolloutHours`. + +Updater clients only discover a release through those `.yml` manifests, so there is no silent 100% admission window before rollout metadata is present. + ### Default behavior `npm run release:patch` → tag push → 24h ramp. No extra action needed. @@ -85,7 +92,7 @@ gh workflow run desktop-rollout.yml \ -f rollout_hours=0 ``` -**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The release manifest goes from `rolloutHours=24` to `rolloutHours=0` within ~30s of publish, and the renderer polls every 30 minutes — so no stable user can admit during the gap. +**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=24`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check. Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.