Package Tomviz Installers #498
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: Package Tomviz Installers | |
| on: | |
| # Rebuild on push to master when packaging code changes | |
| push: | |
| branches: | |
| - main | |
| - master | |
| paths: | |
| - 'packaging/**' | |
| - '.github/workflows/package.yml' | |
| # Poll for new conda-forge releases every 6 hours | |
| schedule: | |
| - cron: '0 */6 * * *' | |
| env: | |
| PYTHON_VERSION: '3.13' | |
| jobs: | |
| check-version: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| tomviz_version: ${{ steps.check.outputs.tomviz_version }} | |
| build_string: ${{ steps.check.outputs.build_string }} | |
| should_build: ${{ steps.check.outputs.should_build }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Check for new version or build | |
| id: check | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PYTHON_VERSION: ${{ env.PYTHON_VERSION }} | |
| run: | | |
| # Get the latest tomviz version and build string from conda-forge. | |
| # If the script errors, set -e aborts the step before we use the values. | |
| LATEST=$(python3 .github/scripts/latest_conda_forge_tomviz.py "$PYTHON_VERSION") | |
| read -r VERSION BUILD_STRING <<< "$LATEST" | |
| echo "Latest conda-forge: $VERSION ($BUILD_STRING)" | |
| echo "tomviz_version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "build_string=$BUILD_STRING" >> "$GITHUB_OUTPUT" | |
| # Always rebuild on push (packaging code changed); otherwise check for new version/build. | |
| if [ "${{ github.event_name }}" = "push" ]; then | |
| echo "Push event — forcing rebuild." | |
| echo "should_build=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Check if we already have a release for this version | |
| EXISTING_NOTES=$(gh release view "v$VERSION" --json body --jq '.body' 2>/dev/null || echo "") | |
| if [ -z "$EXISTING_NOTES" ]; then | |
| echo "No existing release for v$VERSION — will build." | |
| echo "should_build=true" >> "$GITHUB_OUTPUT" | |
| elif echo "$EXISTING_NOTES" | grep -q "build: $BUILD_STRING"; then | |
| echo "Release v$VERSION already matches build $BUILD_STRING — skipping." | |
| echo "should_build=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "Release v$VERSION exists but build changed to $BUILD_STRING — rebuilding." | |
| # The old release will be replaced by create-release if the build succeeds. | |
| echo "should_build=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| build-macos: | |
| needs: check-version | |
| if: needs.check-version.outputs.should_build == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - runner: macos-15-intel | |
| arch: x86_64 | |
| - runner: macos-latest | |
| arch: arm64 | |
| runs-on: ${{ matrix.runner }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: conda-incubator/setup-miniconda@v3 | |
| with: | |
| miniforge-version: latest | |
| environment-file: packaging/environment.yml | |
| conda-remove-defaults: 'true' | |
| - name: Build package | |
| shell: bash -el {0} | |
| env: | |
| TOMVIZ_VERSION: ${{ needs.check-version.outputs.tomviz_version }} | |
| TOMVIZ_PYTHON_VERSION: ${{ env.PYTHON_VERSION }} | |
| run: | | |
| cd packaging | |
| python package.py --tomviz-version "$TOMVIZ_VERSION" --python-version "$TOMVIZ_PYTHON_VERSION" | |
| # Retry cpack up to 3 times (hdiutil detach can fail transiently) | |
| for attempt in 1 2 3; do | |
| if cpack --config CPackConfig.cmake; then | |
| break | |
| fi | |
| echo "CPack attempt $attempt failed, retrying in 10s..." | |
| rm -rf _CPack_Packages | |
| sleep 10 | |
| done | |
| - name: Verify package (structural) | |
| shell: bash -el {0} | |
| env: | |
| TOMVIZ_PYTHON_VERSION: ${{ env.PYTHON_VERSION }} | |
| run: | | |
| cd packaging | |
| APP_DIR=$(find _build/install -name "*.app" -type d | head -1) | |
| python verify.py "$APP_DIR" --python-version "$TOMVIZ_PYTHON_VERSION" | |
| - name: Verify package (smoke test via mounted DMG) | |
| run: | | |
| DMG=$(ls packaging/Tomviz-*.dmg | head -1) | |
| echo "Mounting $DMG ..." | |
| MOUNT_POINT=$(hdiutil attach "$DMG" -nobrowse -readonly -plist | /usr/bin/python3 -c " | |
| import sys, plistlib | |
| data = plistlib.loads(sys.stdin.buffer.read()) | |
| for e in data.get('system-entities', []): | |
| if 'mount-point' in e: | |
| print(e['mount-point']); break | |
| ") | |
| if [ -z "$MOUNT_POINT" ]; then | |
| echo "FAIL: could not determine DMG mount point" | |
| exit 1 | |
| fi | |
| trap 'hdiutil detach "$MOUNT_POINT" 2>/dev/null || true' EXIT | |
| echo "Mounted at: $MOUNT_POINT" | |
| APP="$MOUNT_POINT/tomviz.app" | |
| if [ ! -d "$APP" ]; then | |
| echo "FAIL: tomviz.app not found in DMG" | |
| ls "$MOUNT_POINT" || true | |
| exit 1 | |
| fi | |
| bash .github/scripts/smoke_test_launcher.sh "$APP/Contents/MacOS/tomviz" | |
| - name: Check artifact size | |
| run: bash .github/scripts/check_artifact_size.sh "packaging/Tomviz-*.dmg" | |
| - name: Rename artifact | |
| run: | | |
| mv packaging/Tomviz-*.dmg "packaging/Tomviz-${{ needs.check-version.outputs.tomviz_version }}-${{ matrix.arch }}.dmg" | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: Tomviz-${{ needs.check-version.outputs.tomviz_version }}-${{ matrix.arch }}.dmg | |
| path: packaging/Tomviz-*-${{ matrix.arch }}.dmg | |
| build-windows: | |
| needs: check-version | |
| if: needs.check-version.outputs.should_build == 'true' | |
| runs-on: windows-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| # Add dumpbin (from MSVC) to PATH so verify.py can check DLL deps. | |
| # Uses Microsoft's vswhere (first-party, pre-installed on the runner) to | |
| # locate the latest MSVC toolset; no third-party action involved. | |
| - name: Add MSVC dumpbin to PATH | |
| shell: bash | |
| run: | | |
| VSWHERE="C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe" | |
| VS_PATH=$("$VSWHERE" -latest -property installationPath) | |
| MSVC_VER=$(ls "$VS_PATH/VC/Tools/MSVC" | sort -V | tail -1) | |
| DUMPBIN_DIR="$VS_PATH/VC/Tools/MSVC/$MSVC_VER/bin/Hostx64/x64" | |
| if [ ! -x "$DUMPBIN_DIR/dumpbin.exe" ]; then | |
| echo "FAIL: dumpbin not found at $DUMPBIN_DIR" | |
| exit 1 | |
| fi | |
| echo "Adding to PATH: $DUMPBIN_DIR" | |
| echo "$DUMPBIN_DIR" >> "$GITHUB_PATH" | |
| - uses: conda-incubator/setup-miniconda@v3 | |
| with: | |
| miniforge-version: latest | |
| environment-file: packaging/environment.yml | |
| conda-remove-defaults: 'true' | |
| - name: Build package | |
| shell: bash -el {0} | |
| env: | |
| TOMVIZ_VERSION: ${{ needs.check-version.outputs.tomviz_version }} | |
| TOMVIZ_PYTHON_VERSION: ${{ env.PYTHON_VERSION }} | |
| run: | | |
| cd packaging | |
| python package.py --tomviz-version "$TOMVIZ_VERSION" --python-version "$TOMVIZ_PYTHON_VERSION" | |
| cpack --config CPackConfig.cmake -G ZIP | |
| cpack --config CPackConfig.cmake -G WIX || { | |
| echo "=== WIX log ===" | |
| cat _CPack_Packages/WIX/wix.log 2>/dev/null || echo "wix.log not found" | |
| exit 1 | |
| } | |
| - name: Verify package (structural) | |
| shell: bash -el {0} | |
| env: | |
| TOMVIZ_PYTHON_VERSION: ${{ env.PYTHON_VERSION }} | |
| run: | | |
| cd packaging | |
| python verify.py _build/install/tomviz --python-version "$TOMVIZ_PYTHON_VERSION" | |
| - name: Verify Python imports | |
| shell: bash | |
| run: | | |
| ENV_DIR="packaging/_build/install/tomviz/env" | |
| export PATH="$ENV_DIR/Library/bin:$ENV_DIR/Scripts:$ENV_DIR:$PATH" | |
| echo "=== Testing Python imports ===" | |
| "$ENV_DIR/python.exe" .github/scripts/verify_imports.py | |
| - name: Verify Qt plugins | |
| shell: bash | |
| run: | | |
| ENV_DIR="packaging/_build/install/tomviz/env" | |
| echo "=== Checking Qt plugins ===" | |
| # Qt plugins can be in different locations depending on the conda layout | |
| for PLUGIN_DIR in "$ENV_DIR/Library/plugins" "$ENV_DIR/Library/lib/qt6/plugins"; do | |
| if [ -d "$PLUGIN_DIR/platforms" ]; then | |
| echo "OK: Qt platforms plugin dir exists at $PLUGIN_DIR/platforms" | |
| ls "$PLUGIN_DIR/platforms/" | |
| exit 0 | |
| fi | |
| done | |
| echo "FAIL: Qt platforms plugin dir not found" | |
| exit 1 | |
| - name: Verify MSI | |
| shell: bash | |
| run: | | |
| MSI=$(ls packaging/Tomviz-*.msi 2>/dev/null | head -1) | |
| if [ -z "$MSI" ]; then | |
| echo "FAIL: No MSI found" | |
| exit 1 | |
| fi | |
| echo "=== Validating MSI: $MSI ===" | |
| # Check it's a valid OLE compound file (MSI magic bytes) | |
| MAGIC=$(xxd -l 8 "$MSI" | head -1) | |
| if echo "$MAGIC" | grep -q "d0cf 11e0"; then | |
| echo "OK: Valid MSI file (OLE compound document)" | |
| else | |
| echo "FAIL: Not a valid MSI file" | |
| exit 1 | |
| fi | |
| # Query MSI properties using PowerShell | |
| powershell.exe -Command " | |
| \$db = (New-Object -ComObject WindowsInstaller.Installer).OpenDatabase('$(cygpath -w "$MSI")', 0) | |
| \$view = \$db.OpenView(\"SELECT Property, Value FROM Property WHERE Property='ProductName' OR Property='ProductVersion' OR Property='Manufacturer'\") | |
| \$view.Execute() | |
| while (\$record = \$view.Fetch()) { | |
| Write-Host \" \$(\$record.StringData(1)): \$(\$record.StringData(2))\" | |
| } | |
| " | |
| echo "OK: MSI properties verified" | |
| - name: Verify package (smoke test via extracted ZIP) | |
| shell: bash | |
| env: | |
| QT_QPA_PLATFORM: offscreen | |
| run: | | |
| # We previously ran `msiexec /i ... /qn` here to test the actual MSI | |
| # install path, but it hung indefinitely on the runner (no log | |
| # output, timed out at the workflow limit). Modern Windows Defender | |
| # Tamper Protection also blocks programmatic disable, so we couldn't | |
| # rule out AV interference. Falling back to a ZIP-extraction smoke | |
| # test, which uses the same install layout. The MSI itself is still | |
| # structurally validated by the "Verify MSI" step above. | |
| ZIP=$(ls packaging/Tomviz-*.zip | head -1) | |
| EXTRACT="$RUNNER_TEMP/tomviz-extracted" | |
| rm -rf "$EXTRACT" | |
| mkdir -p "$EXTRACT" | |
| echo "Extracting $ZIP to $EXTRACT ..." | |
| unzip -q "$ZIP" -d "$EXTRACT" | |
| # CPack ZIP wraps everything in Tomviz-${VERSION}/; find tomviz.bat. | |
| LAUNCHER=$(find "$EXTRACT" -name tomviz.bat -type f | head -1) | |
| if [ -z "$LAUNCHER" ] || [ ! -f "$LAUNCHER" ]; then | |
| echo "FAIL: tomviz.bat not found in extracted ZIP" | |
| find "$EXTRACT" -maxdepth 3 -type f | head -20 | |
| exit 1 | |
| fi | |
| echo "Launcher: $LAUNCHER" | |
| bash .github/scripts/smoke_test_launcher.sh "$LAUNCHER" | |
| - name: Check artifact sizes | |
| shell: bash | |
| run: | | |
| bash .github/scripts/check_artifact_size.sh "packaging/Tomviz-*.zip" | |
| bash .github/scripts/check_artifact_size.sh "packaging/Tomviz-*.msi" | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: Tomviz-${{ needs.check-version.outputs.tomviz_version }}.zip | |
| path: packaging/Tomviz-*.zip | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: Tomviz-${{ needs.check-version.outputs.tomviz_version }}.msi | |
| path: packaging/Tomviz-*.msi | |
| build-linux: | |
| needs: check-version | |
| if: needs.check-version.outputs.should_build == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: conda-incubator/setup-miniconda@v3 | |
| with: | |
| miniforge-version: latest | |
| environment-file: packaging/environment.yml | |
| conda-remove-defaults: 'true' | |
| - name: Build package | |
| shell: bash -el {0} | |
| env: | |
| TOMVIZ_VERSION: ${{ needs.check-version.outputs.tomviz_version }} | |
| TOMVIZ_PYTHON_VERSION: ${{ env.PYTHON_VERSION }} | |
| run: | | |
| cd packaging | |
| python package.py --tomviz-version "$TOMVIZ_VERSION" --python-version "$TOMVIZ_PYTHON_VERSION" | |
| cpack --config CPackConfig.cmake | |
| - name: Verify package (structural) | |
| shell: bash -el {0} | |
| env: | |
| TOMVIZ_PYTHON_VERSION: ${{ env.PYTHON_VERSION }} | |
| run: | | |
| cd packaging | |
| python verify.py _build/install/tomviz --python-version "$TOMVIZ_PYTHON_VERSION" | |
| - name: Verify Python imports | |
| run: | | |
| ENV_DIR="packaging/_build/install/tomviz/env" | |
| echo "=== Testing Python imports ===" | |
| "$ENV_DIR/bin/python" .github/scripts/verify_imports.py | |
| - name: Verify package (smoke test via extracted tar.gz) | |
| env: | |
| LIBGL_ALWAYS_SOFTWARE: "1" | |
| run: | | |
| sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libegl1 libgl1-mesa-dri libglx-mesa0 >/dev/null 2>&1 | |
| TGZ=$(ls packaging/Tomviz-*.tar.gz | head -1) | |
| EXTRACT="$RUNNER_TEMP/tomviz-extracted" | |
| rm -rf "$EXTRACT" | |
| mkdir -p "$EXTRACT" | |
| echo "Extracting $TGZ to $EXTRACT ..." | |
| tar -xzf "$TGZ" -C "$EXTRACT" --strip-components=1 | |
| LAUNCHER="$EXTRACT/tomviz/tomviz" | |
| if [ ! -x "$LAUNCHER" ]; then | |
| echo "FAIL: launcher not executable at $LAUNCHER" | |
| ls -la "$EXTRACT/tomviz/" || true | |
| exit 1 | |
| fi | |
| echo "OK: launcher is executable at $LAUNCHER" | |
| xvfb-run -a bash .github/scripts/smoke_test_launcher.sh "$LAUNCHER" | |
| - name: Check artifact size | |
| run: bash .github/scripts/check_artifact_size.sh "packaging/Tomviz-*.tar.gz" | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: Tomviz-${{ needs.check-version.outputs.tomviz_version }}.tar.gz | |
| path: packaging/Tomviz-*.tar.gz | |
| build-linux-rpm: | |
| needs: check-version | |
| if: needs.check-version.outputs.should_build == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: conda-incubator/setup-miniconda@v3 | |
| with: | |
| miniforge-version: latest | |
| environment-file: packaging/environment.yml | |
| conda-remove-defaults: 'true' | |
| - name: Build bundle | |
| shell: bash -el {0} | |
| env: | |
| TOMVIZ_VERSION: ${{ needs.check-version.outputs.tomviz_version }} | |
| TOMVIZ_PYTHON_VERSION: ${{ env.PYTHON_VERSION }} | |
| run: | | |
| cd packaging | |
| python package.py --tomviz-version "$TOMVIZ_VERSION" --python-version "$TOMVIZ_PYTHON_VERSION" | |
| - name: Verify bundle (structural) | |
| shell: bash -el {0} | |
| env: | |
| TOMVIZ_PYTHON_VERSION: ${{ env.PYTHON_VERSION }} | |
| run: | | |
| cd packaging | |
| python verify.py _build/install/tomviz --python-version "$TOMVIZ_PYTHON_VERSION" | |
| - name: Build RPM | |
| shell: bash -el {0} | |
| env: | |
| TOMVIZ_VERSION: ${{ needs.check-version.outputs.tomviz_version }} | |
| run: | | |
| sudo apt-get update -qq && sudo apt-get install -y -qq rpm | |
| cd packaging | |
| bash linux/build_rpm.sh --staged _build/install --version "$TOMVIZ_VERSION" --out _build | |
| - name: Install + smoke test on RHEL 8 and RHEL 9 | |
| env: | |
| TOMVIZ_PYTHON_VERSION: ${{ env.PYTHON_VERSION }} | |
| run: | | |
| cd packaging | |
| RPM=$(cd _build && ls tomviz-*.rpm | head -1) | |
| for img in rockylinux:8 rockylinux:9; do | |
| echo "===================== Testing on $img =====================" | |
| docker run --rm -v "$GITHUB_WORKSPACE:/src" -w /src/packaging "$img" \ | |
| bash -c "bash linux/test_rpm.sh \"/src/packaging/_build/$RPM\" \"/src/packaging/verify.py\" \"$TOMVIZ_PYTHON_VERSION\"" | |
| done | |
| - name: Check artifact size | |
| run: bash .github/scripts/check_artifact_size.sh "packaging/_build/tomviz-*.rpm" 200 | |
| - name: Rename artifact | |
| run: | | |
| mv packaging/_build/tomviz-*.rpm \ | |
| "packaging/Tomviz-${{ needs.check-version.outputs.tomviz_version }}.x86_64.rpm" | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: Tomviz-${{ needs.check-version.outputs.tomviz_version }}.rpm | |
| path: packaging/Tomviz-*.rpm | |
| build-flatpak: | |
| needs: check-version | |
| if: needs.check-version.outputs.should_build == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: conda-incubator/setup-miniconda@v3 | |
| with: | |
| miniforge-version: latest | |
| environment-file: packaging/environment.yml | |
| conda-remove-defaults: 'true' | |
| - name: Build bundle | |
| shell: bash -el {0} | |
| env: | |
| TOMVIZ_VERSION: ${{ needs.check-version.outputs.tomviz_version }} | |
| TOMVIZ_PYTHON_VERSION: ${{ env.PYTHON_VERSION }} | |
| run: | | |
| cd packaging | |
| python package.py --tomviz-version "$TOMVIZ_VERSION" --python-version "$TOMVIZ_PYTHON_VERSION" | |
| - name: Install flatpak tooling | |
| run: | | |
| sudo apt-get update -qq | |
| sudo apt-get install -y -qq flatpak flatpak-builder xvfb \ | |
| mesa-utils libgl1-mesa-dri | |
| flatpak remote-add --user --if-not-exists flathub \ | |
| https://flathub.org/repo/flathub.flatpakrepo | |
| - name: Build flatpak | |
| env: | |
| TOMVIZ_VERSION: ${{ needs.check-version.outputs.tomviz_version }} | |
| run: | | |
| cd packaging | |
| bash flatpak/build_flatpak.sh --staged _build/install --version "$TOMVIZ_VERSION" --out _build | |
| - name: Smoke test flatpak (xvfb + software GL) | |
| env: | |
| TOMVIZ_VERSION: ${{ needs.check-version.outputs.tomviz_version }} | |
| run: | | |
| cd packaging | |
| bash flatpak/test_flatpak.sh "_build/org.tomviz.Tomviz-${TOMVIZ_VERSION}.flatpak" | |
| - name: Check artifact size | |
| run: bash .github/scripts/check_artifact_size.sh "packaging/_build/org.tomviz.Tomviz-*.flatpak" 200 | |
| - name: Rename artifact | |
| run: | | |
| mv packaging/_build/org.tomviz.Tomviz-*.flatpak \ | |
| "packaging/Tomviz-${{ needs.check-version.outputs.tomviz_version }}.flatpak" | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: Tomviz-${{ needs.check-version.outputs.tomviz_version }}.flatpak | |
| path: packaging/Tomviz-*.flatpak | |
| create-release: | |
| needs: [check-version, build-macos, build-windows, build-linux, build-linux-rpm, build-flatpak] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| path: artifacts | |
| - name: Collect release files | |
| run: | | |
| mkdir -p release | |
| # Files are already named correctly from each build job | |
| find artifacts -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.msi" -o -name "*.tar.gz" -o -name "*.rpm" -o -name "*.flatpak" \) -exec cp {} release/ \; | |
| ls -la release/ | |
| - name: Create release on this repo | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| VERSION: ${{ needs.check-version.outputs.tomviz_version }} | |
| BUILD_STRING: ${{ needs.check-version.outputs.build_string }} | |
| run: | | |
| set -euo pipefail | |
| REPO="${{ github.repository }}" | |
| TAG="v${VERSION}" | |
| NOTES="Standalone installers for Tomviz ${VERSION}. | |
| **macOS DMGs and Windows MSI are unsigned.** Download and sign/notarize internally before distribution. | |
| Built from conda-forge packages with Python ${{ env.PYTHON_VERSION }}. | |
| conda-forge build: ${BUILD_STRING}" | |
| # Remove any existing release for this tag (published or draft) so we start | |
| # clean. Reusing the tag is intentional. Only tolerate "release not found"; | |
| # any other error (e.g. a 401) must fail the job instead of being masked, | |
| # otherwise a stale draft silently lingers and is never published. | |
| if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then | |
| gh release delete "$TAG" --repo "$REPO" --cleanup-tag -y | |
| fi | |
| # Create the release as a draft with no assets first. This is a small, fast | |
| # call, separate from the multi-GB upload that intermittently 401s. | |
| gh release create "$TAG" \ | |
| --repo "$REPO" \ | |
| --title "Tomviz ${VERSION}" \ | |
| --notes "$NOTES" \ | |
| --draft | |
| # Upload the large assets separately, retrying on the transient HTTP 401 / | |
| # network failures that have aborted the upload mid-flight. --clobber lets a | |
| # retry overwrite assets that a previous attempt partially uploaded. | |
| for attempt in 1 2 3; do | |
| if gh release upload "$TAG" release/* --repo "$REPO" --clobber; then | |
| break | |
| fi | |
| if [ "$attempt" -eq 3 ]; then | |
| echo "Asset upload failed after $attempt attempts" >&2 | |
| exit 1 | |
| fi | |
| echo "Asset upload attempt $attempt failed; retrying in 30s..." >&2 | |
| sleep 30 | |
| done | |
| # Publish only after every asset is in place, so a failed upload never | |
| # leaves a half-populated published release. | |
| gh release edit "$TAG" --repo "$REPO" --draft=false |