fix(release): publish desktop manifests atomically to close arch race
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.
This commit is contained in:
@@ -164,17 +164,26 @@ jobs:
|
|||||||
PASEO_DESKTOP_SMOKE: "1"
|
PASEO_DESKTOP_SMOKE: "1"
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
publish_mode="never"
|
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
|
||||||
build_args=(-- --publish "$publish_mode" --mac --${{ matrix.electron_arch }})
|
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
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
|
|
||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
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
|
- name: Upload manifest artifact
|
||||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -183,63 +192,6 @@ jobs:
|
|||||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
|
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
|
||||||
retention-days: 1
|
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:
|
publish-linux:
|
||||||
needs: [create-release]
|
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')))) }}
|
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"
|
PASEO_DESKTOP_SMOKE: "1"
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
publish_mode="never"
|
build_args=(-- --publish never --linux --x64)
|
||||||
build_args=(-- --publish "$publish_mode" --linux --x64)
|
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
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
|
|
||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
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:
|
publish-windows:
|
||||||
needs: [create-release]
|
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')))) }}
|
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"
|
PASEO_DESKTOP_SMOKE: "1"
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
publish_mode="never"
|
build_args=(-- --publish never --win --x64)
|
||||||
build_args=(-- --publish "$publish_mode" --win --x64)
|
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
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
|
|
||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
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:
|
finalize-rollout:
|
||||||
needs: [publish-macos, finalize-mac-manifest, publish-linux, publish-windows]
|
needs: [publish-macos, 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') }}
|
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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -428,20 +414,58 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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'
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
mkdir release-manifests
|
|
||||||
cd 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
|
shopt -s nullglob
|
||||||
files=( ./*.yml )
|
files=( "$manifests_dir"/*.yml )
|
||||||
if (( ${#files[@]} == 0 )); then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||||
|
|||||||
+9
-2
@@ -61,10 +61,17 @@ Use the beta path when you need to:
|
|||||||
|
|
||||||
## Staged rollout (stable channel)
|
## 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`.
|
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
|
### Default behavior
|
||||||
|
|
||||||
`npm run release:patch` → tag push → 24h ramp. No extra action needed.
|
`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
|
-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-<tag>`. 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-<tag>`. 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.
|
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user