chore: bump portable-pty from 0.8.1 to 0.9.0 #430
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: CI | ||
| on: | ||
| push: | ||
| branches: [main] | ||
| pull_request: | ||
| branches: [main] | ||
| workflow_dispatch: | ||
| inputs: | ||
| release: | ||
| description: "Cut a release from main (forces a patch bump when there is nothing new to release)" | ||
| type: boolean | ||
| default: false | ||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.ref }} | ||
| cancel-in-progress: true | ||
| env: | ||
| CARGO_TERM_COLOR: always | ||
| CARGO_INCREMENTAL: 0 | ||
| IMAGE_NAME: mearman/cascade-relay | ||
| jobs: | ||
| # ── Change detection ──────────────────────────────────────────────── | ||
| # Gates PWA and Pages jobs on apps/web/** changes so Rust-only pushes | ||
| # don't burn runner minutes on unrelated web tooling. | ||
| changes: | ||
| name: Detect changed paths | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 5 | ||
| outputs: | ||
| web: ${{ steps.check.outputs.web }} | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| fetch-depth: 0 | ||
| - id: check | ||
| shell: bash | ||
| run: | | ||
| if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then | ||
| echo "web=true" >> "$GITHUB_OUTPUT" | ||
| exit 0 | ||
| fi | ||
| if [[ "${{ github.event_name }}" == "pull_request" ]]; then | ||
| BASE="${{ github.event.pull_request.base.sha }}" | ||
| HEAD="${{ github.event.pull_request.head.sha }}" | ||
| else | ||
| BASE="${{ github.event.before }}" | ||
| HEAD="${{ github.event.after }}" | ||
| fi | ||
| if [[ "$BASE" == *"0000000000000000000000000000000000000000"* ]]; then | ||
| echo "web=true" >> "$GITHUB_OUTPUT" | ||
| exit 0 | ||
| fi | ||
| if git diff --name-only "$BASE" "$HEAD" -- apps/web/ | grep -q .; then | ||
| echo "web=true" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "web=false" >> "$GITHUB_OUTPUT" | ||
| fi | ||
| # Lints the browser crates against the wasm32 target. The host clippy in | ||
| # `check` cannot see wasm-only code paths (and the crate-root test-cfg allow | ||
| # masks lints like string_slice on the host), so wasm-only regressions — | ||
| # e.g. a forbidden string slice or a non-Send future pulled into the wasm | ||
| # build — only surface here. Runs unconditionally; it is cheap (three small | ||
| # crates). | ||
| wasm-clippy: | ||
| name: Clippy (wasm32) | ||
| timeout-minutes: 20 | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - name: Install Rust toolchain | ||
| uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| toolchain: "1.96.0" | ||
| components: clippy | ||
| targets: wasm32-unknown-unknown | ||
| - name: Cache Cargo output | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: | | ||
| ~/.cargo/registry | ||
| target/wasm32-unknown-unknown | ||
| key: wasm32-clippy-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} | ||
| restore-keys: wasm32-clippy-${{ runner.os }}- | ||
| - name: Clippy the browser crates (wasm32) | ||
| run: > | ||
| cargo clippy --target wasm32-unknown-unknown | ||
| -p cascade-wasm -p cascade-backend-fsaccess -p cascade-p2p-webrtc | ||
| -- -D warnings | ||
| # Runs the wasm-bindgen-test suite for the two browser bridge crates against | ||
| # JS stub modules under Node.js. Kept separate from wasm-clippy so a | ||
| # stub/runtime failure is distinguishable from a lint failure. | ||
| wasm-test: | ||
| name: wasm-bindgen-test (--node) | ||
| timeout-minutes: 20 | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - name: Install Rust toolchain | ||
| uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| toolchain: "1.96.0" | ||
| targets: wasm32-unknown-unknown | ||
| - name: Setup Node 20 | ||
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | ||
| with: | ||
| node-version: '20' | ||
| - name: Cache Cargo output | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: | | ||
| ~/.cargo/registry | ||
| target/wasm32-unknown-unknown | ||
| key: wasm32-test-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} | ||
| restore-keys: wasm32-test-${{ runner.os }}- | ||
| - name: Install wasm-pack | ||
| # Pin to 0.15.0 so the lane is reproducible. wasm-pack uses the | ||
| # wasm-bindgen version from Cargo.lock so it stays in sync with the | ||
| # workspace pin automatically. | ||
| run: cargo install wasm-pack --version 0.15.0 --locked | ||
| - name: wasm-bindgen-test (cascade-backend-fsaccess) | ||
| run: wasm-pack test --node crates/cascade-backend-fsaccess --features js-test-stub | ||
| - name: wasm-bindgen-test (cascade-p2p-webrtc) | ||
| run: wasm-pack test --node crates/cascade-p2p-webrtc --features js-test-stub | ||
| # Builds, lints, and TESTS the engine under the `portable` feature (the | ||
| # wasm/mobile foundation). `cargo test --workspace` runs default features | ||
| # only, so without this the portable config — RuntimeHandle/StateStorage/ | ||
| # HttpClient/FileSystem/Clock contracts and the wasm adapters — has no CI | ||
| # coverage and regresses silently. Bare `portable` is the wasm config; the | ||
| # `portable,p2p` and `portable,exec` builds guard the mobile cross-compile | ||
| # feature combinations (test-only, since those configs pull native back in). | ||
| portable: | ||
| name: Engine (portable) | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 20 | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| toolchain: "1.96.0" | ||
| components: clippy | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: portable | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Build (portable) | ||
| run: cargo build -p cascade-engine --no-default-features --features portable | ||
| - name: Test (portable) | ||
| run: cargo test -p cascade-engine --no-default-features --features portable --lib | ||
| - name: Clippy (portable) | ||
| run: cargo clippy -p cascade-engine --no-default-features --features portable -- -D warnings | ||
| - name: Build (portable,p2p) | ||
| run: cargo build -p cascade-engine --no-default-features --features portable,p2p | ||
| - name: Build (portable,exec) | ||
| run: cargo build -p cascade-engine --no-default-features --features portable,exec | ||
| source-length: | ||
| name: Source file length | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 5 | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - name: Check no production source file exceeds the line cap | ||
| run: python3 scripts/check-source-file-length.py | ||
| # Builds the iOS File Provider extension: cross-compiles the cascade-ffi | ||
| # static slices, generates the UniFFI Swift bindings, and ad-hoc-signs the | ||
| # Simulator build via xcodebuild. Catches FFI/extension regressions that the | ||
| # host workspace build cannot. macOS runner (Xcode), so it is the costliest | ||
| # gate here; the rust-cache keeps the warm path short. | ||
| ios-app: | ||
| name: Build iOS File Provider extension | ||
| runs-on: macos-latest | ||
| timeout-minutes: 40 | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| toolchain: "1.96.0" | ||
| targets: aarch64-apple-ios,aarch64-apple-ios-sim | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: ios-ffi | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Cache Homebrew | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ~/Library/Caches/Homebrew | ||
| key: homebrew-${{ runner.os }}-xcodegen | ||
| restore-keys: homebrew-${{ runner.os }}- | ||
| - name: Install xcodegen | ||
| run: brew install xcodegen | ||
| - name: Bootstrap (build FFI slices, generate bindings, generate project) | ||
| run: ./swift/CascadeFileProvideriOS/bootstrap.sh | ||
| - name: Build the extension (Simulator, ad-hoc signed) | ||
| run: > | ||
| xcodebuild | ||
| -project swift/CascadeFileProvideriOS/CascadeFileProvideriOS.xcodeproj | ||
| -target CascadeHostApp -sdk iphonesimulator -configuration Debug | ||
| ARCHS=arm64 ONLY_ACTIVE_ARCH=NO build | ||
| - name: Run the logic tests (Simulator) | ||
| # Hostless unit tests for the path<->identifier mapping; the macOS | ||
| # runner ships an iOS Simulator runtime (the dev machine does not), so | ||
| # this is where they actually execute. | ||
| run: > | ||
| xcodebuild | ||
| -project swift/CascadeFileProvideriOS/CascadeFileProvideriOS.xcodeproj | ||
| -scheme FileProviderLogicTests -sdk iphonesimulator | ||
| -destination 'platform=iOS Simulator,name=iPhone 16,arch=arm64' | ||
| -configuration Debug test | ||
| - name: Run the integration tests (Simulator) | ||
| # Drives the REAL CascadeNode through the FFI on the simulator — the | ||
| # FFI -> engine -> local-backend stack on the actual iOS target. | ||
| run: > | ||
| xcodebuild | ||
| -project swift/CascadeFileProvideriOS/CascadeFileProvideriOS.xcodeproj | ||
| -scheme CascadeIntegrationTests -sdk iphonesimulator | ||
| -destination 'platform=iOS Simulator,name=iPhone 16,arch=arm64' | ||
| -configuration Debug test | ||
| - name: Run the File Provider OS-registration e2e (Simulator) | ||
| # Registers a domain via NSFileProviderManager and enumerates through | ||
| # the OS-routed enumerator — proving the OS instantiates the extension | ||
| # and routes enumeration to it. Timing-sensitive; keep going even if it | ||
| # flakes so a slow domain activation does not block the run. | ||
| continue-on-error: true | ||
| run: > | ||
| xcodebuild | ||
| -project swift/CascadeFileProvideriOS/CascadeFileProvideriOS.xcodeproj | ||
| -scheme FileProviderRegistrationE2ETests -sdk iphonesimulator | ||
| -destination 'platform=iOS Simulator,name=iPhone 16,arch=arm64' | ||
| -configuration Debug test | ||
| - name: Package the Simulator app | ||
| # The ad-hoc signed Simulator .app — drag into a booted simulator to run. | ||
| # Not a device build: a real iPhone needs a signed .ipa (see release-ios-ipa). | ||
| run: > | ||
| ditto -c -k --keepParent | ||
| swift/CascadeFileProvideriOS/build/Debug-iphonesimulator/CascadeHostApp.app | ||
| "$RUNNER_TEMP/cascade-ios-simulator-app.zip" | ||
| - name: Upload the Simulator app (CI artifact) | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | ||
| with: | ||
| name: cascade-ios-simulator-app | ||
| path: ${{ runner.temp }}/cascade-ios-simulator-app.zip | ||
| if-no-files-found: error | ||
| # Builds the Android DocumentsProvider app: cross-compiles the cascade-ffi | ||
| # shared library for both ABIs into jniLibs and runs gradle assembleDebug. | ||
| # ubuntu-latest ships the Android SDK, an NDK, and JDK 17. | ||
| android-app: | ||
| name: Build Android DocumentsProvider app | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 30 | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| toolchain: "1.96.0" | ||
| targets: aarch64-linux-android,x86_64-linux-android | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: android-ffi | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Resolve the preinstalled NDK and JDK 17 | ||
| shell: bash | ||
| run: | | ||
| echo "ANDROID_NDK=$(ls -d "$ANDROID_SDK_ROOT"/ndk/*/ | sort -V | tail -1)" >> "$GITHUB_ENV" | ||
| echo "JAVA_HOME=$JAVA_HOME_17_X64" >> "$GITHUB_ENV" | ||
| - name: Cache Gradle | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: | | ||
| ~/.gradle/caches | ||
| ~/.gradle/wrapper | ||
| key: gradle-${{ runner.os }}-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle.properties') }} | ||
| restore-keys: gradle-${{ runner.os }}- | ||
| - name: Cache Android SDK platform and build-tools | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: | | ||
| ${{ env.ANDROID_SDK_ROOT }}/platforms/android-34 | ||
| ${{ env.ANDROID_SDK_ROOT }}/build-tools/34.0.0 | ||
| key: android-sdk-34-bt34-${{ runner.os }} | ||
| - name: Ensure the SDK platform and build-tools the app targets | ||
| shell: bash | ||
| run: | | ||
| # `set +o pipefail` so `yes` getting SIGPIPE when sdkmanager exits | ||
| # (the runner default is `-o pipefail`) does not fail the step; the | ||
| # pipeline's status is then sdkmanager's own exit code. Idempotent: | ||
| # already-installed packages (restored from cache) are a fast no-op. | ||
| set +o pipefail | ||
| yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" "platforms;android-34" "build-tools;34.0.0" >/dev/null | ||
| - name: Bootstrap (cross-compile the .so slices into jniLibs) | ||
| run: ./android/bootstrap.sh | ||
| - name: Assemble the debug APK | ||
| working-directory: android | ||
| run: ./gradlew :app:assembleDebug --no-daemon | ||
| - name: Run JVM unit tests | ||
| working-directory: android | ||
| run: ./gradlew :app:testDebugUnitTest --no-daemon | ||
| - name: Upload the debug APK (CI artifact) | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | ||
| with: | ||
| name: cascade-android-debug-apk | ||
| path: android/app/build/outputs/apk/debug/app-debug.apk | ||
| if-no-files-found: error | ||
| # On-device e2e for the Android DocumentsProvider: boots an x86_64 emulator | ||
| # and runs the instrumented test, which drives the REAL provider through a | ||
| # ContentResolver (provider -> CascadeNode -> FFI -> engine -> local backend, | ||
| # native .so and all). `continue-on-error` because emulator boots on GitHub's | ||
| # free runners are flaky; flip to blocking once it's proven green reliably. | ||
| android-e2e: | ||
| name: Android DocumentsProvider e2e (emulator) | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 45 | ||
| continue-on-error: true | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 | ||
| with: | ||
| distribution: temurin | ||
| java-version: '17' | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| toolchain: "1.96.0" | ||
| targets: aarch64-linux-android,x86_64-linux-android | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: android-ffi | ||
| - name: Resolve the preinstalled NDK and SDK | ||
| shell: bash | ||
| run: | | ||
| echo "ANDROID_NDK=$(ls -d "$ANDROID_SDK_ROOT"/ndk/*/ | sort -V | tail -1)" >> "$GITHUB_ENV" | ||
| echo "sdk.dir=$ANDROID_SDK_ROOT" > android/local.properties | ||
| - name: Cache Gradle | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: | | ||
| ~/.gradle/caches | ||
| ~/.gradle/wrapper | ||
| key: gradle-${{ runner.os }}-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle.properties') }} | ||
| restore-keys: gradle-${{ runner.os }}- | ||
| - name: Cache Android SDK platform and build-tools | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: | | ||
| ${{ env.ANDROID_SDK_ROOT }}/platforms/android-34 | ||
| ${{ env.ANDROID_SDK_ROOT }}/build-tools/34.0.0 | ||
| key: android-sdk-34-bt34-${{ runner.os }} | ||
| - name: Ensure the SDK platform and build-tools the app targets | ||
| shell: bash | ||
| run: | | ||
| set +o pipefail | ||
| yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" "platforms;android-34" "build-tools;34.0.0" >/dev/null | ||
| - name: Bootstrap (cross-compile the .so slices into jniLibs) | ||
| run: ./android/bootstrap.sh | ||
| - name: Run the instrumented tests on the emulator | ||
| uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 | ||
| with: | ||
| api-level: 34 | ||
| arch: x86_64 | ||
| target: default | ||
| emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim | ||
| # Disable the package verifier before installing the test APK. The | ||
| # emulator's integrity verification stalls intermittently under load | ||
| # and surfaces as INSTALL_FAILED_VERIFICATION_FAILURE, which fails the | ||
| # instrumented test run before any test executes. There is nothing to | ||
| # verify on a self-signed debug APK on a throwaway emulator. | ||
| script: >- | ||
| adb shell settings put global package_verifier_enable 0 && | ||
| adb shell settings put global verifier_verify_adb_installs 0 && | ||
| ./gradlew :app:connectedDebugAndroidTest --no-daemon | ||
| working-directory: android | ||
| # Attaches the Android debug APK to the GitHub release. Runs only when the | ||
| # release job actually cut a release, mirroring the cascade-binary release | ||
| # jobs. Debug-signed (no keystore secret needed); installable for testing, | ||
| # though MANAGE_DOCUMENTS only registers the provider on a system install. | ||
| release-android: | ||
| name: Release Android APK | ||
| needs: release | ||
| if: needs.release.outputs.released == 'true' | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 30 | ||
| permissions: | ||
| contents: write | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| targets: aarch64-linux-android,x86_64-linux-android | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: android-ffi | ||
| - name: Resolve the preinstalled NDK and JDK 17 | ||
| shell: bash | ||
| run: | | ||
| echo "ANDROID_NDK=$(ls -d "$ANDROID_SDK_ROOT"/ndk/*/ | sort -V | tail -1)" >> "$GITHUB_ENV" | ||
| echo "JAVA_HOME=$JAVA_HOME_17_X64" >> "$GITHUB_ENV" | ||
| - name: Cache Gradle | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: | | ||
| ~/.gradle/caches | ||
| ~/.gradle/wrapper | ||
| key: gradle-${{ runner.os }}-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle.properties') }} | ||
| restore-keys: gradle-${{ runner.os }}- | ||
| - name: Cache Android SDK platform and build-tools | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: | | ||
| ${{ env.ANDROID_SDK_ROOT }}/platforms/android-34 | ||
| ${{ env.ANDROID_SDK_ROOT }}/build-tools/34.0.0 | ||
| key: android-sdk-34-bt34-${{ runner.os }} | ||
| - name: Ensure the SDK platform and build-tools the app targets | ||
| shell: bash | ||
| run: | | ||
| set +o pipefail | ||
| yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" "platforms;android-34" "build-tools;34.0.0" >/dev/null | ||
| - name: Bootstrap (cross-compile the .so slices into jniLibs) | ||
| run: ./android/bootstrap.sh | ||
| - name: Assemble the debug APK | ||
| working-directory: android | ||
| run: ./gradlew :app:assembleDebug --no-daemon | ||
| - name: Package the APK with a versioned name | ||
| run: | | ||
| mkdir -p dist | ||
| cp android/app/build/outputs/apk/debug/app-debug.apk dist/cascade-android.apk | ||
| shasum -a 256 dist/cascade-android.apk | awk '{print $1}' > dist/cascade-android.apk.sha256 | ||
| - name: Upload to release | ||
| uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 | ||
| with: | ||
| tag_name: ${{ needs.release.outputs.version }} | ||
| files: dist/cascade-android.apk* | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| # Builds and attaches a signed iOS .ipa to the release. iOS requires a | ||
| # provisioning profile to install on any device, so this needs Apple signing | ||
| # secrets and is skipped (without failing) when they are absent. Required | ||
| # secrets (see swift/CascadeFileProvideriOS/README.md): IOS_DIST_CERT_P12 | ||
| # (base64 .p12), IOS_DIST_CERT_PASSWORD, IOS_KEYCHAIN_PASSWORD, | ||
| # IOS_PROVISIONING_PROFILE (base64 .mobileprovision), IOS_TEAM_ID, | ||
| # IOS_EXPORT_METHOD (e.g. development / ad-hoc / app-store). | ||
| release-ios-ipa: | ||
| name: Release iOS IPA (signed) | ||
| needs: release | ||
| if: needs.release.outputs.released == 'true' | ||
| runs-on: macos-latest | ||
| timeout-minutes: 40 | ||
| permissions: | ||
| contents: write | ||
| steps: | ||
| - name: Detect signing secrets | ||
| id: signing | ||
| env: | ||
| CERT: ${{ secrets.IOS_DIST_CERT_P12 }} | ||
| run: | | ||
| if [ -n "$CERT" ]; then echo "have=true" >> "$GITHUB_OUTPUT"; else echo "have=false" >> "$GITHUB_OUTPUT"; fi | ||
| - name: Note skipped (no signing secrets) | ||
| if: steps.signing.outputs.have != 'true' | ||
| run: echo "iOS signing secrets are not configured; skipping the IPA build. See swift/CascadeFileProvideriOS/README.md." | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| if: steps.signing.outputs.have == 'true' | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| if: steps.signing.outputs.have == 'true' | ||
| with: | ||
| targets: aarch64-apple-ios | ||
| - name: Install xcodegen | ||
| if: steps.signing.outputs.have == 'true' | ||
| run: brew install xcodegen | ||
| - name: Import signing certificate | ||
| if: steps.signing.outputs.have == 'true' | ||
| env: | ||
| CERT_P12: ${{ secrets.IOS_DIST_CERT_P12 }} | ||
| CERT_PW: ${{ secrets.IOS_DIST_CERT_PASSWORD }} | ||
| KEYCHAIN_PW: ${{ secrets.IOS_KEYCHAIN_PASSWORD }} | ||
| run: | | ||
| echo "$CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12" | ||
| security create-keychain -p "$KEYCHAIN_PW" build.keychain | ||
| security default-keychain -s build.keychain | ||
| security unlock-keychain -p "$KEYCHAIN_PW" build.keychain | ||
| security import "$RUNNER_TEMP/cert.p12" -k build.keychain -P "$CERT_PW" -T /usr/bin/codesign | ||
| security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PW" build.keychain | ||
| - name: Install provisioning profile | ||
| if: steps.signing.outputs.have == 'true' | ||
| env: | ||
| PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} | ||
| run: | | ||
| mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" | ||
| echo "$PROFILE" | base64 --decode > "$HOME/Library/MobileDevice/Provisioning Profiles/cascade.mobileprovision" | ||
| - name: Bootstrap (build FFI device slice, bindings, project) | ||
| if: steps.signing.outputs.have == 'true' | ||
| run: ./swift/CascadeFileProvideriOS/bootstrap.sh | ||
| - name: Archive and export the signed IPA | ||
| if: steps.signing.outputs.have == 'true' | ||
| env: | ||
| TEAM_ID: ${{ secrets.IOS_TEAM_ID }} | ||
| EXPORT_METHOD: ${{ secrets.IOS_EXPORT_METHOD }} | ||
| working-directory: swift/CascadeFileProvideriOS | ||
| run: | | ||
| cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST | ||
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| <plist version="1.0"><dict> | ||
| <key>method</key><string>${EXPORT_METHOD:-development}</string> | ||
| <key>teamID</key><string>${TEAM_ID}</string> | ||
| <key>signingStyle</key><string>manual</string> | ||
| </dict></plist> | ||
| PLIST | ||
| xcodebuild -project CascadeFileProvideriOS.xcodeproj -scheme CascadeHostApp \ | ||
| -sdk iphoneos -configuration Release -archivePath "$RUNNER_TEMP/Cascade.xcarchive" \ | ||
| DEVELOPMENT_TEAM="$TEAM_ID" archive | ||
| xcodebuild -exportArchive -archivePath "$RUNNER_TEMP/Cascade.xcarchive" \ | ||
| -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" -exportPath "$RUNNER_TEMP/ipa" | ||
| mkdir -p dist && cp "$RUNNER_TEMP"/ipa/*.ipa dist/cascade-ios.ipa | ||
| - name: Upload to release | ||
| if: steps.signing.outputs.have == 'true' | ||
| uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 | ||
| with: | ||
| tag_name: ${{ needs.release.outputs.version }} | ||
| files: swift/CascadeFileProvideriOS/dist/cascade-ios.ipa | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| # Attaches the ad-hoc-signed iOS Simulator app to the release — the | ||
| # counterpart to the Android APK, runnable in a booted simulator with no | ||
| # Apple account (unzip, then drag into the simulator). A real-device build is | ||
| # the separate, secrets-gated release-ios-ipa job. | ||
| release-ios-simulator: | ||
| name: Release iOS Simulator app | ||
| needs: release | ||
| if: needs.release.outputs.released == 'true' | ||
| runs-on: macos-latest | ||
| timeout-minutes: 40 | ||
| permissions: | ||
| contents: write | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| targets: aarch64-apple-ios,aarch64-apple-ios-sim | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: ios-ffi | ||
| - name: Cache Homebrew | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ~/Library/Caches/Homebrew | ||
| key: homebrew-${{ runner.os }}-xcodegen | ||
| restore-keys: homebrew-${{ runner.os }}- | ||
| - name: Install xcodegen | ||
| run: brew install xcodegen | ||
| - name: Bootstrap (build FFI slices, generate bindings, generate project) | ||
| run: ./swift/CascadeFileProvideriOS/bootstrap.sh | ||
| - name: Build the extension (Simulator, ad-hoc signed) | ||
| run: > | ||
| xcodebuild | ||
| -project swift/CascadeFileProvideriOS/CascadeFileProvideriOS.xcodeproj | ||
| -target CascadeHostApp -sdk iphonesimulator -configuration Debug | ||
| ARCHS=arm64 ONLY_ACTIVE_ARCH=NO build | ||
| - name: Package the Simulator app | ||
| run: | | ||
| mkdir -p dist | ||
| ditto -c -k --keepParent \ | ||
| swift/CascadeFileProvideriOS/build/Debug-iphonesimulator/CascadeHostApp.app \ | ||
| dist/cascade-ios-simulator.zip | ||
| shasum -a 256 dist/cascade-ios-simulator.zip | awk '{print $1}' > dist/cascade-ios-simulator.zip.sha256 | ||
| - name: Upload to release | ||
| uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 | ||
| with: | ||
| tag_name: ${{ needs.release.outputs.version }} | ||
| files: dist/cascade-ios-simulator.zip* | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| # Verifies the iOS *archive* half of the IPA pipeline with no signing | ||
| # identity: `xcodebuild archive` builds a valid .xcarchive unsigned (only the | ||
| # final `exportArchive` step needs a profile). Runs always — no secrets — so | ||
| # the build path of release-ios-ipa is proven green, leaving only the signed | ||
| # export gated on the IOS_* secrets. | ||
| ios-archive-check: | ||
| name: iOS archive (unsigned) | ||
| runs-on: macos-latest | ||
| timeout-minutes: 40 | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| toolchain: "1.96.0" | ||
| targets: aarch64-apple-ios | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: ios-ffi | ||
| - name: Cache Homebrew | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ~/Library/Caches/Homebrew | ||
| key: homebrew-${{ runner.os }}-xcodegen | ||
| restore-keys: homebrew-${{ runner.os }}- | ||
| - name: Install xcodegen | ||
| run: brew install xcodegen | ||
| - name: Bootstrap (build FFI device slice, bindings, project) | ||
| run: ./swift/CascadeFileProvideriOS/bootstrap.sh | ||
| - name: Archive the app (unsigned) | ||
| run: > | ||
| xcodebuild | ||
| -project swift/CascadeFileProvideriOS/CascadeFileProvideriOS.xcodeproj | ||
| -scheme CascadeHostApp -sdk iphoneos -configuration Release | ||
| -archivePath "$RUNNER_TEMP/Cascade.xcarchive" | ||
| CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO DEVELOPMENT_TEAM="" | ||
| archive | ||
| check: | ||
| name: Check (${{ matrix.target }}) | ||
| timeout-minutes: 30 | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| # x86_64-apple-darwin is covered at release time by the | ||
| # cross-compiled build-macos-x86 job; running native tests on | ||
| # macos-13 (Intel) here is value-low and macos-13 runners are | ||
| # heavily oversubscribed. | ||
| include: | ||
| - os: ubuntu-latest | ||
| target: x86_64-unknown-linux-gnu | ||
| - os: ubuntu-24.04-arm | ||
| target: aarch64-unknown-linux-gnu | ||
| - os: macos-latest | ||
| target: aarch64-apple-darwin | ||
| - os: windows-2025-vs2026 | ||
| target: x86_64-pc-windows-msvc | ||
| runs-on: ${{ matrix.os }} | ||
| # sccache wraps rustc so individual compilation units survive release-plz's | ||
| # per-release Cargo.lock churn (which invalidates rust-cache's target/ | ||
| # wholesale). rust-cache below still carries the registry/git/deps. | ||
| env: | ||
| RUSTC_WRAPPER: sccache | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| components: rustfmt, clippy | ||
| - name: Install sccache | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: sccache | ||
| - name: Configure sccache dir | ||
| # runner.temp is not available in job-level env, so export SCCACHE_DIR | ||
| # here (bash is present on every runner OS, so this is uniform across | ||
| # Linux/macOS/Windows). It matches the actions/cache path below. | ||
| shell: bash | ||
| run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache" >> "$GITHUB_ENV" | ||
| - name: Restore sccache | ||
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| id: sccache | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-check-${{ matrix.target }}-${{ github.sha }} | ||
| restore-keys: sccache-check-${{ matrix.target }}- | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| # Share the compiled-dependency cache with the `test` job on the same | ||
| # target — dependency builds dominate, and clippy vs test only differ | ||
| # in the workspace crates' own artifacts. `save-if` restricts cache | ||
| # writes to main so feature-branch churn can't evict the warm | ||
| # main-line cache (GHA's 10 GB LRU). | ||
| shared-key: ${{ matrix.target }} | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Install system dependencies (Linux) | ||
| if: runner.os == 'Linux' | ||
| run: sudo apt-get update && sudo apt-get install -y libfuse3-dev | ||
| - name: Format | ||
| run: cargo fmt --check | ||
| - name: Clippy | ||
| # --all-targets so test and bench code is linted too, not just lib/bin. | ||
| # -D warnings promotes every clippy warning (including default-level | ||
| # lints the workspace table does not explicitly deny) to an error. | ||
| # The four restriction lints (unwrap_used/expect_used/indexing_slicing/ | ||
| # string_slice) are scoped out of test code at each crate root — see | ||
| # the comment in the root Cargo.toml's [workspace.lints.clippy]. | ||
| run: cargo clippy --workspace --all-targets -- -D warnings | ||
| - name: Clippy (feature-gated discovery + integration code) | ||
| # The Clippy step above builds default features only, so the | ||
| # dht / announce / nat-integration code is never linted there. Lint it | ||
| # explicitly — once, on Linux, since the gated code compiles the same on | ||
| # every platform — so a regression in those paths fails CI rather than | ||
| # only surfacing for whoever happens to build the feature. | ||
| if: runner.os == 'Linux' | ||
| run: cargo clippy -p cascade-p2p --all-targets --features "dht announce nat-integration" -- -D warnings | ||
| - name: Docs | ||
| run: cargo doc --workspace --no-deps --document-private-items | ||
| env: | ||
| RUSTDOCFLAGS: "-D warnings" | ||
| - name: sccache stats | ||
| run: sccache --show-stats | ||
| - name: Save sccache | ||
| # Mirror rust-cache's save-if policy: only main writes the cache so | ||
| # feature-branch churn can't evict the warm main-line entry. Also | ||
| # gate on a non-empty sccache dir: actions/cache/save emits a | ||
| # 'Cache save failed' run-summary annotation whenever the path is | ||
| # empty, which is noisy and not informative — a build that didn't | ||
| # touch sccache has nothing to save and should just skip the step. | ||
| if: github.ref == 'refs/heads/main' && hashFiles('${{ runner.temp }}/sccache/**') != '' | ||
|
Check warning on line 715 in .github/workflows/ci.yml
|
||
| # sccache is a build-time accelerator; an empty or failed cache save | ||
| # is a noisy warning, not a real failure. Continue on error so the | ||
| # post-build upload doesn't pollute the run summary. | ||
| continue-on-error: true | ||
| uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-check-${{ matrix.target }}-${{ github.sha }} | ||
| test: | ||
| name: Test (${{ matrix.target }}) | ||
| timeout-minutes: 30 | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| include: | ||
| - os: ubuntu-latest | ||
| target: x86_64-unknown-linux-gnu | ||
| - os: ubuntu-24.04-arm | ||
| target: aarch64-unknown-linux-gnu | ||
| - os: macos-latest | ||
| target: aarch64-apple-darwin | ||
| - os: windows-2025-vs2026 | ||
| target: x86_64-pc-windows-msvc | ||
| runs-on: ${{ matrix.os }} | ||
| env: | ||
| RUSTC_WRAPPER: sccache | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| - name: Install sccache | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: sccache | ||
| - name: Configure sccache dir | ||
| # runner.temp is not available in job-level env, so export SCCACHE_DIR | ||
| # here (bash is present on every runner OS, so this is uniform across | ||
| # Linux/macOS/Windows). It matches the actions/cache path below. | ||
| shell: bash | ||
| run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache" >> "$GITHUB_ENV" | ||
| - name: Restore sccache | ||
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-test-${{ matrix.target }}-${{ github.sha }} | ||
| restore-keys: sccache-test-${{ matrix.target }}- | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| shared-key: ${{ matrix.target }} | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Install system dependencies (Linux) | ||
| if: runner.os == 'Linux' | ||
| run: sudo apt-get update && sudo apt-get install -y libfuse3-dev | ||
| - name: Run tests | ||
| run: cargo test --workspace | ||
| - name: sccache stats | ||
| run: sccache --show-stats | ||
| - name: Save sccache | ||
| if: github.ref == 'refs/heads/main' | ||
| # sccache is a build-time accelerator; an empty or failed cache save | ||
| # is a noisy warning, not a real failure. Continue on error so the | ||
| # post-build upload doesn't pollute the run summary. | ||
| continue-on-error: true | ||
| uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-test-${{ matrix.target }}-${{ github.sha }} | ||
| nat-integration: | ||
| name: NAT integration (Linux netns) | ||
| needs: [check, test] | ||
| timeout-minutes: 20 | ||
| runs-on: ubuntu-latest | ||
| env: | ||
| RUSTC_WRAPPER: sccache | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - name: Install Rust toolchain | ||
| run: | | ||
| curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable | ||
| echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" | ||
| - name: Install sccache | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: sccache | ||
| - name: Configure sccache dir | ||
| # runner.temp is not available in job-level env, so export SCCACHE_DIR | ||
| # here (bash is present on every runner OS, so this is uniform across | ||
| # Linux/macOS/Windows). It matches the actions/cache path below. | ||
| shell: bash | ||
| run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache" >> "$GITHUB_ENV" | ||
| - name: Restore sccache | ||
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-nat-integration-${{ github.sha }} | ||
| restore-keys: sccache-nat-integration- | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| # rust-cache rather than a lockfile-hash blob: it keys on rustc | ||
| # version, prunes stale deps, and survives release-plz's constant | ||
| # Cargo.lock bumps that fully invalidated the old hashFiles key. | ||
| key: nat-integration | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Install system dependencies | ||
| run: sudo apt-get update && sudo apt-get install -y libfuse3-dev iproute2 iptables | ||
| - name: Build nat-integration test binaries | ||
| # Build the operated-relay test and the serverless-rung tests up front | ||
| # (without sudo) so the sudo invocations below only have to run, not | ||
| # compile — keeping the privileged surface as small as possible. This | ||
| # non-privileged build is where sccache earns its keep; the sudo steps | ||
| # below run as root and deliberately bypass sccache (RUSTC_WRAPPER=) | ||
| # so they never write root-owned files into the runner-owned cache dir. | ||
| run: | | ||
| cargo test -p cascade-p2p --features nat-integration --test nat_integration --no-run | ||
| cargo test -p cascade-p2p --features nat-integration --test serverless_rungs --no-run | ||
| - name: Run NAT integration tests (requires root for CAP_NET_ADMIN) | ||
| # sudo -E forwards the full environment (including PATH so cargo is | ||
| # found) and HOME so the Cargo registry cache is accessible. | ||
| # RUSTC_WRAPPER= disables sccache under root so the cache dir stays | ||
| # owned by the runner user and the save step can read it. | ||
| run: | | ||
| sudo -E env "PATH=$PATH" "HOME=$HOME" "RUSTC_WRAPPER=" \ | ||
| cargo test -p cascade-p2p \ | ||
| --features nat-integration \ | ||
| --test nat_integration \ | ||
| -- --test-threads=1 --nocapture | ||
| - name: Run serverless connectivity-rung tests (peer-as-STUN + peer relay) | ||
| # Proves the fully serverless rungs of the connectivity ladder with no | ||
| # operated servers of any kind: (a) a cone-NAT pair connects via | ||
| # peer-as-STUN observed-address learning plus hole punch, and (b) a | ||
| # symmetric-NAT pair bridges through a third open peer acting as a peer | ||
| # relay. Both assert a block transfer completes over the expected rung. | ||
| run: | | ||
| sudo -E env "PATH=$PATH" "HOME=$HOME" "RUSTC_WRAPPER=" \ | ||
| cargo test -p cascade-p2p \ | ||
| --features nat-integration \ | ||
| --test serverless_rungs \ | ||
| -- --test-threads=1 --nocapture | ||
| - name: sccache stats | ||
| run: sccache --show-stats | ||
| - name: Save sccache | ||
| if: github.ref == 'refs/heads/main' | ||
| # sccache is a build-time accelerator; an empty or failed cache save | ||
| # is a noisy warning, not a real failure. Continue on error so the | ||
| # post-build upload doesn't pollute the run summary. | ||
| continue-on-error: true | ||
| uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-nat-integration-${{ github.sha }} | ||
| dht-live: | ||
| name: DHT live tests (local mainline testnet) | ||
| needs: [check, test] | ||
| runs-on: ubuntu-latest | ||
| env: | ||
| RUSTC_WRAPPER: sccache | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - name: Install Rust toolchain | ||
| run: | | ||
| curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable | ||
| echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" | ||
| - name: Install sccache | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: sccache | ||
| - name: Configure sccache dir | ||
| # runner.temp is not available in job-level env, so export SCCACHE_DIR | ||
| # here (bash is present on every runner OS, so this is uniform across | ||
| # Linux/macOS/Windows). It matches the actions/cache path below. | ||
| shell: bash | ||
| run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache" >> "$GITHUB_ENV" | ||
| - name: Restore sccache | ||
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-dht-live-${{ github.sha }} | ||
| restore-keys: sccache-dht-live- | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: dht-live | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Clippy the live DHT tests under their feature | ||
| # The standard workspace clippy job builds default features only, so the | ||
| # `#[cfg(feature = "dht")]` live_tests module is never linted there. The | ||
| # "Clippy (feature-gated discovery + integration code)" step in the check | ||
| # job lints it under `dht` already; re-assert it here, scoped to the same | ||
| # crate and feature, so this job fails on a lint regression in the live | ||
| # test code before it spends time bootstrapping a swarm. | ||
| run: cargo clippy -p cascade-p2p --all-targets --features dht -- -D warnings | ||
| - name: Build DHT live test binary | ||
| # Compile up front (without running) so the run step below only has to | ||
| # bootstrap the swarm, not build — keeping the timed run step lean. | ||
| run: cargo test -p cascade-p2p --features dht --lib discovery::dht::live_tests --no-run | ||
| - name: Run DHT live tests against an ephemeral local testnet | ||
| # The live_tests module is `#[ignore]`'d so the standard offline | ||
| # `cargo test --workspace` stays green; this job opts them in with | ||
| # `--ignored`. They spin up an in-process `mainline::Testnet` — a small | ||
| # swarm of DHT nodes bound to local UDP sockets — and never touch the | ||
| # public BitTorrent bootstrap routers, so the run is fully hermetic. | ||
| # Single-threaded so the swarms do not contend, and `timeout-minutes` | ||
| # bounds the whole step so a wedged lookup fails the job rather than | ||
| # hanging it (each resolve is itself bounded by DHT_RESOLVE_TIMEOUT). | ||
| timeout-minutes: 10 | ||
| run: | | ||
| cargo test -p cascade-p2p \ | ||
| --features dht \ | ||
| --lib discovery::dht::live_tests \ | ||
| -- --ignored --test-threads=1 --nocapture | ||
| - name: sccache stats | ||
| run: sccache --show-stats | ||
| - name: Save sccache | ||
| if: github.ref == 'refs/heads/main' | ||
| # sccache is a build-time accelerator; an empty or failed cache save | ||
| # is a noisy warning, not a real failure. Continue on error so the | ||
| # post-build upload doesn't pollute the run summary. | ||
| continue-on-error: true | ||
| uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-dht-live-${{ github.sha }} | ||
| projfs-probe: | ||
| name: ProjFS availability probe (Windows) | ||
| needs: [check, test] | ||
| timeout-minutes: 20 | ||
| runs-on: windows-2025-vs2026 | ||
| steps: | ||
| # Diagnostic only: confirms whether the Windows Projected File System | ||
| # (Client-ProjFS) optional feature is enabled on the hosted runner and | ||
| # whether its PrjFlt filter driver can be brought up without a reboot. | ||
| # This de-risks implementing the ProjFS read/write callbacks (roadmap | ||
| # v8) by establishing — empirically, on the exact image CI gets — | ||
| # whether a real ProjFS integration test could run here at all. The | ||
| # runner-images toolset lists Client-ProjFS in its windowsFeatures, but | ||
| # the published manifest can lag the live image and the 2025 migration | ||
| # trimmed some components, so we check rather than assume. | ||
| - name: Report Windows build | ||
| shell: pwsh | ||
| run: | | ||
| Get-ComputerInfo -Property OsName, OsVersion, OsBuildNumber | | ||
| Format-List | ||
| - name: Probe Client-ProjFS optional feature | ||
| shell: pwsh | ||
| run: | | ||
| $feature = Get-WindowsOptionalFeature -Online -FeatureName Client-ProjFS -ErrorAction SilentlyContinue | ||
| if ($null -eq $feature) { | ||
| Write-Error "Client-ProjFS optional feature is not present on this image." | ||
| exit 1 | ||
| } | ||
| Write-Host "Client-ProjFS state: $($feature.State)" | ||
| if ($feature.State -ne 'Enabled') { | ||
| Write-Error "Client-ProjFS is present but not Enabled (state: $($feature.State)). A ProjFS integration test would need to enable it, which may require a reboot CI cannot perform." | ||
| exit 1 | ||
| } | ||
| - name: Probe PrjFlt filter driver | ||
| shell: pwsh | ||
| # The projfs callbacks run behind the PrjFlt minifilter. Even with the | ||
| # optional feature Enabled, confirm the driver is loadable so a future | ||
| # integration test can attach a virtualization root. `fltmc` needs an | ||
| # elevated context — GitHub runners run as admin by default. | ||
| run: | | ||
| $existing = fltmc filters | Select-String -Quiet 'PrjFlt' | ||
| if ($existing) { | ||
| Write-Host "PrjFlt minifilter already loaded." | ||
| } else { | ||
| Write-Host "PrjFlt not loaded; attempting fltmc load PrjFlt..." | ||
| fltmc load PrjFlt | ||
| if ($LASTEXITCODE -ne 0) { | ||
| Write-Error "fltmc load PrjFlt failed (exit $LASTEXITCODE) — driver not loadable without a reboot on this image." | ||
| exit 1 | ||
| } | ||
| Write-Host "PrjFlt loaded successfully." | ||
| } | ||
| fltmc filters | Select-String 'PrjFlt' | ||
| - name: Summary | ||
| shell: pwsh | ||
| run: Write-Host "ProjFS is available on this runner — a Windows-only ProjFS integration test is feasible here." | ||
| projfs-live: | ||
| name: ProjFS live integration (Windows) | ||
| needs: [check, test] | ||
| timeout-minutes: 20 | ||
| runs-on: windows-2025-vs2026 | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: projfs-live | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Verify ProjFS prerequisites | ||
| shell: pwsh | ||
| # Re-check the same preconditions the projfs-probe job confirms, so a | ||
| # regression in the moving windows-latest image fails this job with a | ||
| # clear message rather than surfacing an opaque PrjStartVirtualizing | ||
| # HRESULT out of Rust. The separate probe job de-risks the image; this | ||
| # guard protects this job's own run. | ||
| run: | | ||
| $feature = Get-WindowsOptionalFeature -Online -FeatureName Client-ProjFS -ErrorAction SilentlyContinue | ||
| if ($null -eq $feature -or $feature.State -ne 'Enabled') { | ||
| $state = if ($null -eq $feature) { 'absent' } else { $feature.State } | ||
| Write-Error "Client-ProjFS is not Enabled (state: $state) — the live ProjFS test cannot run on this image." | ||
| exit 1 | ||
| } | ||
| Write-Host "Client-ProjFS state: $($feature.State)" | ||
| $loaded = fltmc filters | Select-String -Quiet 'PrjFlt' | ||
| if (-not $loaded) { | ||
| Write-Host "PrjFlt not loaded; attempting fltmc load PrjFlt..." | ||
| fltmc load PrjFlt | ||
| if ($LASTEXITCODE -ne 0) { | ||
| Write-Error "fltmc load PrjFlt failed (exit $LASTEXITCODE) — the PrjFlt minifilter is not loadable on this image." | ||
| exit 1 | ||
| } | ||
| } | ||
| Write-Host "PrjFlt minifilter is loaded." | ||
| - name: Clippy live ProjFS test under its feature | ||
| # The projfs-live test file is gated behind the feature + Windows, so | ||
| # the workspace clippy job never lints it. Lint it here under the same | ||
| # gate so the test code is actually held to the workspace lint bar. | ||
| run: cargo clippy -p cascade-presenter-projfs --features projfs-live --all-targets -- -D warnings | ||
| - name: Build live ProjFS test binary | ||
| run: cargo test -p cascade-presenter-projfs --features projfs-live --test projfs_live --no-run | ||
| - name: Run live ProjFS integration test | ||
| # Single-threaded so the live mount and its OS callbacks are not raced | ||
| # by parallel test functions. No elevation step is needed beyond what | ||
| # fltmc requires: GitHub Windows runners run elevated by default, which | ||
| # is sufficient for PrjStartVirtualizing on a runner-owned directory. | ||
| run: cargo test -p cascade-presenter-projfs --features projfs-live --test projfs_live -- --test-threads=1 --nocapture | ||
| e2e-p2p: | ||
| name: E2E (P2P mesh in Docker Compose) | ||
| needs: [check, test] | ||
| timeout-minutes: 30 | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - name: Verify Docker Compose | ||
| run: docker compose version | ||
| - name: Run P2P e2e harness | ||
| run: test/e2e/p2p/run.sh | ||
| env: | ||
| # Larger build context fits comfortably in 7 GB-class runners. | ||
| DOCKER_BUILDKIT: "1" | ||
| - name: Dump compose logs on failure | ||
| if: failure() | ||
| working-directory: test/e2e/p2p | ||
| run: docker compose logs --tail=200 || true | ||
| # ── PWA (was .github/workflows/pwa.yml) ──────────────────────────── | ||
| pwa-check: | ||
| name: PWA typecheck | ||
| needs: [changes] | ||
| if: needs.changes.outputs.web == 'true' | ||
| timeout-minutes: 10 | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 | ||
| with: | ||
| version: 11 | ||
| - name: Cache node_modules | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ~/.pnpm-store | ||
| key: pnpm-${{ runner.os }}-${{ hashFiles('apps/web/pnpm-lock.yaml') }} | ||
| restore-keys: pnpm-${{ runner.os }}- | ||
| - name: Install dependencies | ||
| run: pnpm install | ||
| working-directory: apps/web | ||
| - name: TypeScript type check | ||
| run: pnpm run typecheck | ||
| working-directory: apps/web | ||
| - name: Lint | ||
| run: pnpm run lint | ||
| working-directory: apps/web | ||
| - name: Unit tests | ||
| run: pnpm run test:run | ||
| working-directory: apps/web | ||
| pwa-build: | ||
| name: PWA build | ||
| needs: [changes, pwa-check] | ||
| if: needs.changes.outputs.web == 'true' | ||
| timeout-minutes: 10 | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 | ||
| with: | ||
| version: 11 | ||
| - name: Cache node_modules | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ~/.pnpm-store | ||
| key: pnpm-${{ runner.os }}-${{ hashFiles('apps/web/pnpm-lock.yaml') }} | ||
| restore-keys: pnpm-${{ runner.os }}- | ||
| - name: Install dependencies | ||
| run: pnpm install | ||
| working-directory: apps/web | ||
| - name: Ensure WASM placeholder | ||
| run: rm -f public/wasm && mkdir -p public/wasm | ||
| working-directory: apps/web | ||
| - name: Build | ||
| run: pnpm run build | ||
| working-directory: apps/web | ||
| - name: Upload dist artifact | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | ||
| with: | ||
| name: pwa-dist | ||
| path: apps/web/dist/ | ||
| retention-days: 1 | ||
| release: | ||
| name: Release | ||
| needs: [check, test] | ||
| # Auto on push to main, or on demand via `workflow_dispatch` with the | ||
| # `release` input ticked (a dispatch without it just runs CI). | ||
| if: > | ||
| github.ref == 'refs/heads/main' && | ||
| (github.event_name == 'push' || | ||
| (github.event_name == 'workflow_dispatch' && inputs.release)) | ||
| # release-plz bumps Cargo.lock on every release, which invalidates | ||
| # rust-cache's target/ wholesale, and the sccache Save steps only run on a | ||
| # main push — so the first release after a cold/evicted cache rebuilds the | ||
| # full workspace from scratch. 60 minutes gives that cold path headroom; | ||
| # warm releases finish well inside it. | ||
| timeout-minutes: 60 | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| released: ${{ steps.release.outputs.released }} | ||
| version: ${{ steps.release.outputs.version }} | ||
| permissions: | ||
| contents: write | ||
| env: | ||
| RUSTC_WRAPPER: sccache | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| fetch-depth: 0 | ||
| token: ${{ secrets.GITHUB_TOKEN }} | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| - name: Install sccache | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: sccache | ||
| - name: Configure sccache dir | ||
| # runner.temp is not available in job-level env, so export SCCACHE_DIR | ||
| # here (bash is present on every runner OS, so this is uniform across | ||
| # Linux/macOS/Windows). It matches the actions/cache path below. | ||
| shell: bash | ||
| run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache" >> "$GITHUB_ENV" | ||
| - name: Restore sccache | ||
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-release-${{ github.sha }} | ||
| restore-keys: sccache-release- | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Install release-plz | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: release-plz | ||
| - name: Bump versions | ||
| run: | | ||
| release-plz update 2>&1 | ||
| - name: Commit and push version bump | ||
| id: bump | ||
| run: | | ||
| git config user.name "github-actions[bot]" | ||
| git config user.email "github-actions[bot]@users.noreply.github.com" | ||
| git add -A | ||
| if git diff --cached --quiet; then | ||
| # release-plz found nothing to release. On a manual dispatch the | ||
| # operator explicitly asked for a release, so force a patch bump | ||
| # rather than no-op; on a push, leave it as a clean no-release. | ||
| if [ "${{ github.event_name }}" != "workflow_dispatch" ]; then | ||
| echo 'No version bump to commit' | ||
| echo "bumped=false" >> "$GITHUB_OUTPUT" | ||
| exit 0 | ||
| fi | ||
| CUR=$(grep '^version = ' Cargo.toml | head -1 | sed -E 's/version = "(.*)"/\1/') | ||
| NEW=$(echo "$CUR" | awk -F. '{printf "%d.%d.%d", $1, $2, $3 + 1}') | ||
| awk -v new="$NEW" '!done && /^version = "[^"]*"/ {sub(/"[^"]*"/, "\"" new "\""); done=1} {print}' Cargo.toml > Cargo.toml.tmp | ||
| mv Cargo.toml.tmp Cargo.toml | ||
| # Sync the workspace crates' versions in Cargo.lock (not their deps). | ||
| cargo update --workspace | ||
| git add -A | ||
| echo "Forced patch bump v${CUR} -> v${NEW} (manual release, no new releasable commits)" | ||
| fi | ||
| VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed -E 's/version = "(.*)"/\1/') | ||
| # [skip ci] keeps this bot bump from spawning a second, redundant CI | ||
| # run: the release and its asset builds already ran as downstream | ||
| # jobs of the push that triggered this one, so re-running the full | ||
| # matrix here only burns minutes and cancels the original run's | ||
| # trailing jobs via the cancel-in-progress concurrency group. | ||
| git commit -m "chore: release v${VERSION} [skip ci]" | ||
| git push | ||
| echo "bumped=true" >> "$GITHUB_OUTPUT" | ||
| echo "version=${VERSION}" >> "$GITHUB_OUTPUT" | ||
| echo "Released v${VERSION}" | ||
| - name: Create releases | ||
| id: release | ||
| if: steps.bump.outputs.bumped == 'true' | ||
| run: | | ||
| set +e | ||
| OUTPUT=$(release-plz release --git-token ${{ secrets.GITHUB_TOKEN }} 2>&1) | ||
| RC=$? | ||
| set -e | ||
| echo "$OUTPUT" | ||
| # release-plz logs lines like "released cascade 0.1.1 (git-only)". | ||
| # With multiple packages in the workspace, release-plz publishes | ||
| # crate-prefixed tags (cascade-v0.1.1), not the bare v0.1.1. | ||
| if echo "$OUTPUT" | grep -q 'released cascade '; | ||
| then | ||
| VERSION=$(echo "$OUTPUT" | grep 'released cascade ' | head -1 | grep -oP 'released cascade \K[^ ]+') | ||
| TAG="cascade-v${VERSION}" | ||
| echo "released=true" >> "$GITHUB_OUTPUT" | ||
| echo "version=${TAG}" >> "$GITHUB_OUTPUT" | ||
| echo "Detected release: ${TAG}" | ||
| else | ||
| echo "released=false" >> "$GITHUB_OUTPUT" | ||
| echo 'No cascade binary release detected' | ||
| fi | ||
| exit $RC | ||
| - name: sccache stats | ||
| run: sccache --show-stats | ||
| - name: Save sccache | ||
| if: github.ref == 'refs/heads/main' | ||
| # sccache is a build-time accelerator; an empty or failed cache save | ||
| # is a noisy warning, not a real failure. Continue on error so the | ||
| # post-build upload doesn't pollute the run summary. | ||
| continue-on-error: true | ||
| uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-release-${{ github.sha }} | ||
| build-macos-arm: | ||
| name: Build macOS ARM | ||
| needs: release | ||
| if: needs.release.outputs.released == 'true' | ||
| # Full-workspace release build gated on a release: see the release job — | ||
| # the same Cargo.lock churn + cold sccache means a near-clean compile of | ||
| # the whole dependency graph. 60 minutes covers the cold path on | ||
| # oversubscribed macOS runners; warm builds finish well inside it. | ||
| timeout-minutes: 60 | ||
| runs-on: macos-latest | ||
| permissions: | ||
| contents: write | ||
| env: | ||
| RUSTC_WRAPPER: sccache | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| targets: aarch64-apple-darwin | ||
| - name: Install sccache | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: sccache | ||
| - name: Configure sccache dir | ||
| # runner.temp is not available in job-level env, so export SCCACHE_DIR | ||
| # here (bash is present on every runner OS, so this is uniform across | ||
| # Linux/macOS/Windows). It matches the actions/cache path below. | ||
| shell: bash | ||
| run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache" >> "$GITHUB_ENV" | ||
| - name: Restore sccache | ||
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-build-aarch64-apple-darwin-${{ github.sha }} | ||
| restore-keys: sccache-build-aarch64-apple-darwin- | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: build-aarch64-apple-darwin | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Build | ||
| run: cargo build --release --target aarch64-apple-darwin -p cascade | ||
| env: | ||
| CASCADE_GDRIVE_CLIENT_ID: ${{ secrets.CASCADE_GDRIVE_CLIENT_ID }} | ||
| CASCADE_GDRIVE_CLIENT_SECRET: ${{ secrets.CASCADE_GDRIVE_CLIENT_SECRET }} | ||
| - name: sccache stats | ||
| run: sccache --show-stats | ||
| - name: Save sccache | ||
| if: github.ref == 'refs/heads/main' | ||
| # sccache is a build-time accelerator; an empty or failed cache save | ||
| # is a noisy warning, not a real failure. Continue on error so the | ||
| # post-build upload doesn't pollute the run summary. | ||
| continue-on-error: true | ||
| uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-build-aarch64-apple-darwin-${{ github.sha }} | ||
| - name: Package archive | ||
| run: | | ||
| mkdir -p dist | ||
| cp target/aarch64-apple-darwin/release/cascade dist/cascade | ||
| cd dist | ||
| tar czf cascade-aarch64-macos.tar.gz cascade | ||
| shasum -a 256 cascade-aarch64-macos.tar.gz | awk '{print $1}' > cascade-aarch64-macos.tar.gz.sha256 | ||
| - name: Upload to release | ||
| uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 | ||
| with: | ||
| tag_name: ${{ needs.release.outputs.version }} | ||
| files: dist/cascade-aarch64-macos.tar.gz* | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| build-macos-x86: | ||
| name: Build macOS x86_64 | ||
| needs: release | ||
| if: needs.release.outputs.released == 'true' | ||
| # Full-workspace cross-compile gated on a release — cold sccache plus the | ||
| # release-plz Cargo.lock bump means a near-clean build of the whole | ||
| # dependency graph. 60 minutes covers the cold path; warm builds finish | ||
| # well inside it. | ||
| timeout-minutes: 60 | ||
| runs-on: macos-latest | ||
| permissions: | ||
| contents: write | ||
| env: | ||
| RUSTC_WRAPPER: sccache | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| targets: x86_64-apple-darwin | ||
| - run: rustup target add x86_64-apple-darwin | ||
| - name: Install sccache | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: sccache | ||
| - name: Configure sccache dir | ||
| # runner.temp is not available in job-level env, so export SCCACHE_DIR | ||
| # here (bash is present on every runner OS, so this is uniform across | ||
| # Linux/macOS/Windows). It matches the actions/cache path below. | ||
| shell: bash | ||
| run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache" >> "$GITHUB_ENV" | ||
| - name: Restore sccache | ||
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-build-x86_64-apple-darwin-${{ github.sha }} | ||
| restore-keys: sccache-build-x86_64-apple-darwin- | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: build-x86_64-apple-darwin | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Build | ||
| run: cargo build --release --target x86_64-apple-darwin -p cascade | ||
| env: | ||
| CASCADE_GDRIVE_CLIENT_ID: ${{ secrets.CASCADE_GDRIVE_CLIENT_ID }} | ||
| CASCADE_GDRIVE_CLIENT_SECRET: ${{ secrets.CASCADE_GDRIVE_CLIENT_SECRET }} | ||
| - name: sccache stats | ||
| run: sccache --show-stats | ||
| - name: Save sccache | ||
| if: github.ref == 'refs/heads/main' | ||
| # sccache is a build-time accelerator; an empty or failed cache save | ||
| # is a noisy warning, not a real failure. Continue on error so the | ||
| # post-build upload doesn't pollute the run summary. | ||
| continue-on-error: true | ||
| uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-build-x86_64-apple-darwin-${{ github.sha }} | ||
| - name: Package archive | ||
| run: | | ||
| mkdir -p dist | ||
| cp target/x86_64-apple-darwin/release/cascade dist/cascade | ||
| cd dist | ||
| tar czf cascade-x86_64-macos.tar.gz cascade | ||
| shasum -a 256 cascade-x86_64-macos.tar.gz | awk '{print $1}' > cascade-x86_64-macos.tar.gz.sha256 | ||
| - name: Upload to release | ||
| uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 | ||
| with: | ||
| tag_name: ${{ needs.release.outputs.version }} | ||
| files: dist/cascade-x86_64-macos.tar.gz* | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| build-linux-x86: | ||
| name: Build Linux x86_64 | ||
| needs: release | ||
| if: needs.release.outputs.released == 'true' | ||
| # Full-workspace release build gated on a release — cold sccache plus the | ||
| # release-plz Cargo.lock bump means a near-clean build of the whole | ||
| # dependency graph. 60 minutes covers the cold path; warm builds finish | ||
| # well inside it. | ||
| timeout-minutes: 60 | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: write | ||
| env: | ||
| RUSTC_WRAPPER: sccache | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| - name: Install sccache | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: sccache | ||
| - name: Configure sccache dir | ||
| # runner.temp is not available in job-level env, so export SCCACHE_DIR | ||
| # here (bash is present on every runner OS, so this is uniform across | ||
| # Linux/macOS/Windows). It matches the actions/cache path below. | ||
| shell: bash | ||
| run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache" >> "$GITHUB_ENV" | ||
| - name: Restore sccache | ||
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-build-x86_64-unknown-linux-gnu-${{ github.sha }} | ||
| restore-keys: sccache-build-x86_64-unknown-linux-gnu- | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: build-x86_64-unknown-linux-gnu | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Install system dependencies | ||
| run: sudo apt-get update && sudo apt-get install -y libfuse3-dev | ||
| - name: Build | ||
| run: cargo build --release --target x86_64-unknown-linux-gnu -p cascade | ||
| env: | ||
| CASCADE_GDRIVE_CLIENT_ID: ${{ secrets.CASCADE_GDRIVE_CLIENT_ID }} | ||
| CASCADE_GDRIVE_CLIENT_SECRET: ${{ secrets.CASCADE_GDRIVE_CLIENT_SECRET }} | ||
| - name: sccache stats | ||
| run: sccache --show-stats | ||
| - name: Save sccache | ||
| if: github.ref == 'refs/heads/main' | ||
| # sccache is a build-time accelerator; an empty or failed cache save | ||
| # is a noisy warning, not a real failure. Continue on error so the | ||
| # post-build upload doesn't pollute the run summary. | ||
| continue-on-error: true | ||
| uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-build-x86_64-unknown-linux-gnu-${{ github.sha }} | ||
| - name: Package archive | ||
| run: | | ||
| mkdir -p dist | ||
| cp target/x86_64-unknown-linux-gnu/release/cascade dist/cascade | ||
| cd dist | ||
| tar czf cascade-x86_64-linux.tar.gz cascade | ||
| sha256sum cascade-x86_64-linux.tar.gz | awk '{print $1}' > cascade-x86_64-linux.tar.gz.sha256 | ||
| - name: Upload to release | ||
| uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 | ||
| with: | ||
| tag_name: ${{ needs.release.outputs.version }} | ||
| files: dist/cascade-x86_64-linux.tar.gz* | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| build-linux-arm: | ||
| name: Build Linux ARM64 | ||
| needs: release | ||
| if: needs.release.outputs.released == 'true' | ||
| # Full-workspace cross-compile gated on a release — cold sccache plus the | ||
| # release-plz Cargo.lock bump means a near-clean build of the whole | ||
| # dependency graph. 60 minutes covers the cold path; warm builds finish | ||
| # well inside it. | ||
| timeout-minutes: 60 | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: write | ||
| env: | ||
| RUSTC_WRAPPER: sccache | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| targets: aarch64-unknown-linux-gnu | ||
| - run: rustup target add aarch64-unknown-linux-gnu | ||
| - name: Install sccache | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: sccache | ||
| - name: Configure sccache dir | ||
| # runner.temp is not available in job-level env, so export SCCACHE_DIR | ||
| # here (bash is present on every runner OS, so this is uniform across | ||
| # Linux/macOS/Windows). It matches the actions/cache path below. | ||
| shell: bash | ||
| run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache" >> "$GITHUB_ENV" | ||
| - name: Restore sccache | ||
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-build-aarch64-unknown-linux-gnu-${{ github.sha }} | ||
| restore-keys: sccache-build-aarch64-unknown-linux-gnu- | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: build-aarch64-unknown-linux-gnu | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Install cross-compilation toolchain | ||
| run: | | ||
| sudo apt-get update | ||
| sudo apt-get install -y gcc-aarch64-linux-gnu | ||
| - name: Build | ||
| run: cargo build --release --target aarch64-unknown-linux-gnu -p cascade | ||
| env: | ||
| CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc | ||
| CASCADE_GDRIVE_CLIENT_ID: ${{ secrets.CASCADE_GDRIVE_CLIENT_ID }} | ||
| CASCADE_GDRIVE_CLIENT_SECRET: ${{ secrets.CASCADE_GDRIVE_CLIENT_SECRET }} | ||
| - name: sccache stats | ||
| run: sccache --show-stats | ||
| - name: Save sccache | ||
| if: github.ref == 'refs/heads/main' | ||
| # sccache is a build-time accelerator; an empty or failed cache save | ||
| # is a noisy warning, not a real failure. Continue on error so the | ||
| # post-build upload doesn't pollute the run summary. | ||
| continue-on-error: true | ||
| uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-build-aarch64-unknown-linux-gnu-${{ github.sha }} | ||
| - name: Package archive | ||
| run: | | ||
| mkdir -p dist | ||
| cp target/aarch64-unknown-linux-gnu/release/cascade dist/cascade | ||
| cd dist | ||
| tar czf cascade-aarch64-linux.tar.gz cascade | ||
| sha256sum cascade-aarch64-linux.tar.gz | awk '{print $1}' > cascade-aarch64-linux.tar.gz.sha256 | ||
| - name: Upload to release | ||
| uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 | ||
| with: | ||
| tag_name: ${{ needs.release.outputs.version }} | ||
| files: dist/cascade-aarch64-linux.tar.gz* | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| build-windows-x86: | ||
| name: Build Windows x86_64 | ||
| needs: release | ||
| if: needs.release.outputs.released == 'true' | ||
| # Full-workspace release build gated on a release — cold sccache plus the | ||
| # release-plz Cargo.lock bump means a near-clean build of the whole | ||
| # dependency graph, and Windows linking is the slowest of the matrix. | ||
| # 60 minutes covers the cold path; warm builds finish well inside it. | ||
| timeout-minutes: 60 | ||
| runs-on: windows-2025-vs2026 | ||
| permissions: | ||
| contents: write | ||
| env: | ||
| RUSTC_WRAPPER: sccache | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| targets: x86_64-pc-windows-msvc | ||
| - name: Install sccache | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: sccache | ||
| - name: Configure sccache dir | ||
| # runner.temp is not available in job-level env, so export SCCACHE_DIR | ||
| # here (bash is present on every runner OS, so this is uniform across | ||
| # Linux/macOS/Windows). It matches the actions/cache path below. | ||
| shell: bash | ||
| run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache" >> "$GITHUB_ENV" | ||
| - name: Restore sccache | ||
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-build-x86_64-pc-windows-msvc-${{ github.sha }} | ||
| restore-keys: sccache-build-x86_64-pc-windows-msvc- | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: build-x86_64-pc-windows-msvc | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Build | ||
| run: cargo build --release --target x86_64-pc-windows-msvc -p cascade | ||
| env: | ||
| CASCADE_GDRIVE_CLIENT_ID: ${{ secrets.CASCADE_GDRIVE_CLIENT_ID }} | ||
| CASCADE_GDRIVE_CLIENT_SECRET: ${{ secrets.CASCADE_GDRIVE_CLIENT_SECRET }} | ||
| - name: sccache stats | ||
| run: sccache --show-stats | ||
| - name: Save sccache | ||
| if: github.ref == 'refs/heads/main' | ||
| # sccache is a build-time accelerator; an empty or failed cache save | ||
| # is a noisy warning, not a real failure. Continue on error so the | ||
| # post-build upload doesn't pollute the run summary. | ||
| continue-on-error: true | ||
| uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-build-x86_64-pc-windows-msvc-${{ github.sha }} | ||
| - name: Package archive | ||
| shell: pwsh | ||
| run: | | ||
| New-Item -ItemType Directory -Force -Path dist | Out-Null | ||
| Copy-Item target/x86_64-pc-windows-msvc/release/cascade.exe dist/cascade.exe | ||
| Compress-Archive -Path dist/cascade.exe -DestinationPath dist/cascade-x86_64-windows.zip | ||
| $hash = (Get-FileHash -Algorithm SHA256 dist/cascade-x86_64-windows.zip).Hash.ToLower() | ||
| $hash | Out-File -Encoding ascii -NoNewline dist/cascade-x86_64-windows.zip.sha256 | ||
| - name: Upload to release | ||
| uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 | ||
| with: | ||
| tag_name: ${{ needs.release.outputs.version }} | ||
| files: dist/cascade-x86_64-windows.zip* | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| update-tap: | ||
| name: Update Homebrew tap | ||
| needs: [release, build-macos-arm, build-macos-x86, build-linux-x86, build-linux-arm] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 10 | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout tap via SSH deploy key | ||
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| repository: Mearman/homebrew-cascade | ||
| ssh-key: ${{ secrets.TAP_DEPLOY_KEY }} | ||
| - name: Download checksums | ||
| env: | ||
| VERSION: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| TAG_URL="https://github.com/Mearman/cascade/releases/download/${VERSION}" | ||
| # `--fail` makes curl exit non-zero on 4xx/5xx so a Not-Found body | ||
| # doesn't get piped through awk and end up in the formula (see the | ||
| # v0.1.33 Scoop bucket regression where `Hash: Not` shipped). | ||
| # `--retry` covers the small propagation window between | ||
| # `softprops/action-gh-release` returning success and the asset | ||
| # being downloadable from the public CDN. Validate that what we | ||
| # got is a 64-char lowercase hex sha256 before letting it through. | ||
| fetch_sha() { | ||
| local name="$1" | ||
| local out="$2" | ||
| curl -sL --fail --retry 6 --retry-delay 5 --retry-all-errors \ | ||
| "${TAG_URL}/${name}.sha256" | awk '{print $1}' > "${out}" | ||
| if ! [[ "$(cat "${out}")" =~ ^[0-9a-f]{64}$ ]]; then | ||
| echo "::error::checksum for ${name} not a 64-char hex digest: $(cat "${out}")" | ||
| exit 1 | ||
| fi | ||
| } | ||
| fetch_sha cascade-aarch64-macos.tar.gz sha-macos-arm | ||
| fetch_sha cascade-x86_64-macos.tar.gz sha-macos-x86 | ||
| fetch_sha cascade-x86_64-linux.tar.gz sha-linux-x86 | ||
| fetch_sha cascade-aarch64-linux.tar.gz sha-linux-arm | ||
| - name: Generate formula | ||
| env: | ||
| VERSION: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| VERSION_NUM="${VERSION#cascade-v}" | ||
| MACOS_ARM_SHA=$(cat sha-macos-arm) | ||
| MACOS_X86_SHA=$(cat sha-macos-x86) | ||
| LINUX_X86_SHA=$(cat sha-linux-x86) | ||
| LINUX_ARM_SHA=$(cat sha-linux-arm) | ||
| mkdir -p Formula | ||
| cat > Formula/cascade.rb <<EOF | ||
| # Auto-generated by CI — do not edit manually. | ||
| # Regenerated on each semantic release from Mearman/cascade. | ||
| class Cascade < Formula | ||
| desc "Cross-platform cloud storage filesystem client" | ||
| homepage "https://github.com/Mearman/cascade" | ||
| version "${VERSION_NUM}" | ||
| license "MIT" | ||
| on_macos do | ||
| if Hardware::CPU.arm? | ||
| url "https://github.com/Mearman/cascade/releases/download/${VERSION}/cascade-aarch64-macos.tar.gz" | ||
| sha256 "${MACOS_ARM_SHA}" | ||
| else | ||
| url "https://github.com/Mearman/cascade/releases/download/${VERSION}/cascade-x86_64-macos.tar.gz" | ||
| sha256 "${MACOS_X86_SHA}" | ||
| end | ||
| end | ||
| on_linux do | ||
| if Hardware::CPU.arm? | ||
| url "https://github.com/Mearman/cascade/releases/download/${VERSION}/cascade-aarch64-linux.tar.gz" | ||
| sha256 "${LINUX_ARM_SHA}" | ||
| else | ||
| url "https://github.com/Mearman/cascade/releases/download/${VERSION}/cascade-x86_64-linux.tar.gz" | ||
| sha256 "${LINUX_X86_SHA}" | ||
| end | ||
| end | ||
| def install | ||
| bin.install "cascade" | ||
| end | ||
| service do | ||
| run [opt_bin/"cascade", "start"] | ||
| keep_alive true | ||
| log_path var/"log/cascade.log" | ||
| error_log_path var/"log/cascade.err.log" | ||
| end | ||
| test do | ||
| assert_match version.to_s, shell_output("\#{bin}/cascade --version") | ||
| end | ||
| end | ||
| EOF | ||
| - name: Commit and push | ||
| env: | ||
| VERSION: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| git config user.name "github-actions[bot]" | ||
| git config user.email "github-actions[bot]@users.noreply.github.com" | ||
| git add Formula/cascade.rb | ||
| git diff --cached --quiet || git commit -m "chore: update formula to ${VERSION}" | ||
| git push | ||
| update-scoop: | ||
| name: Update Scoop bucket | ||
| needs: [release, build-windows-x86] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 10 | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout bucket via SSH deploy key | ||
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| repository: Mearman/scoop-cascade | ||
| ssh-key: ${{ secrets.SCOOP_DEPLOY_KEY }} | ||
| - name: Download checksum | ||
| env: | ||
| VERSION: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| TAG_URL="https://github.com/Mearman/cascade/releases/download/${VERSION}" | ||
| # See the matching block in `update-tap` for the rationale — | ||
| # this is the regression that landed in v0.1.33 as `Hash: Not`. | ||
| curl -sL --fail --retry 6 --retry-delay 5 --retry-all-errors \ | ||
| "${TAG_URL}/cascade-x86_64-windows.zip.sha256" | awk '{print $1}' > sha-windows-x86 | ||
| if ! [[ "$(cat sha-windows-x86)" =~ ^[0-9a-f]{64}$ ]]; then | ||
| echo "::error::Windows checksum not a 64-char hex digest: $(cat sha-windows-x86)" | ||
| exit 1 | ||
| fi | ||
| - name: Generate manifest | ||
| env: | ||
| VERSION: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| VERSION_NUM="${VERSION#cascade-v}" | ||
| WINDOWS_X86_SHA=$(cat sha-windows-x86) | ||
| mkdir -p bucket | ||
| cat > bucket/cascade.json <<EOF | ||
| { | ||
| "version": "${VERSION_NUM}", | ||
| "description": "Cross-platform cloud storage filesystem client", | ||
| "homepage": "https://github.com/Mearman/cascade", | ||
| "license": "MIT", | ||
| "architecture": { | ||
| "64bit": { | ||
| "url": "https://github.com/Mearman/cascade/releases/download/${VERSION}/cascade-x86_64-windows.zip", | ||
| "hash": "${WINDOWS_X86_SHA}" | ||
| } | ||
| }, | ||
| "bin": "cascade.exe", | ||
| "checkver": { | ||
| "github": "https://github.com/Mearman/cascade" | ||
| }, | ||
| "autoupdate": { | ||
| "architecture": { | ||
| "64bit": { | ||
| "url": "https://github.com/Mearman/cascade/releases/download/v\$version/cascade-x86_64-windows.zip" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| EOF | ||
| - name: Commit and push | ||
| env: | ||
| VERSION: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| git config user.name "github-actions[bot]" | ||
| git config user.email "github-actions[bot]@users.noreply.github.com" | ||
| git add bucket/cascade.json | ||
| git diff --cached --quiet || git commit -m "chore: update manifest to ${VERSION}" | ||
| git push | ||
| verify-brew-macos: | ||
| name: Verify Homebrew install (macOS) | ||
| needs: [release, update-tap] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 15 | ||
| runs-on: macos-latest | ||
| steps: | ||
| - name: Cache Homebrew downloads | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ~/Library/Caches/Homebrew/downloads | ||
| key: brew-cache-macos-${{ needs.release.outputs.version }} | ||
| restore-keys: brew-cache-macos- | ||
| - name: Tap Homebrew formula | ||
| run: brew tap Mearman/cascade | ||
| - name: Install via Homebrew | ||
| run: brew install Mearman/cascade/cascade | ||
| - name: Run cascade --version | ||
| run: | | ||
| cascade --version | ||
| # Verify the installed version matches the release tag. | ||
| INSTALLED=$(cascade --version | awk '{print $2}') | ||
| EXPECTED="${VERSION#cascade-v}" | ||
| if [ "$INSTALLED" != "$EXPECTED" ]; then | ||
| echo "version mismatch: installed=$INSTALLED expected=$EXPECTED" | ||
| exit 1 | ||
| fi | ||
| env: | ||
| VERSION: ${{ needs.release.outputs.version }} | ||
| verify-brew-linux: | ||
| name: Verify Homebrew install (Linux) | ||
| needs: [release, update-tap] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 15 | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Cache Linuxbrew downloads | ||
| # Only cache the bottle download dir — caching the full prefix | ||
| # would restore an older cascade binary across versions and | ||
| # `brew install` would no-op, causing the version check below | ||
| # to fail with the previous release's version. | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ~/.cache/Homebrew/downloads | ||
| key: linuxbrew-downloads-${{ needs.release.outputs.version }} | ||
| restore-keys: linuxbrew-downloads- | ||
| - name: Install Homebrew on Linux | ||
| run: | | ||
| if ! command -v brew >/dev/null 2>&1 && ! [ -x /home/linuxbrew/.linuxbrew/bin/brew ]; then | ||
| /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" </dev/null | ||
| fi | ||
| echo "/home/linuxbrew/.linuxbrew/bin" >> $GITHUB_PATH | ||
| - name: Tap Homebrew formula | ||
| # brew tap can trigger SIGPIPE (exit 141) on Linux when its verbose | ||
| # output overflows the Actions runner's stdout pipe. The tap itself | ||
| # succeeds — the failure is in output flushing. Suppress stdout and | ||
| # verify the tap directory exists instead of relying on the exit code. | ||
| run: | | ||
| brew tap Mearman/cascade >/dev/null || true | ||
| test -d /home/linuxbrew/.linuxbrew/Homebrew/Library/Taps/mearman/homebrew-cascade | ||
| - name: Install via Homebrew | ||
| run: brew install Mearman/cascade/cascade | ||
| - name: Run cascade --version | ||
| run: | | ||
| cascade --version | ||
| INSTALLED=$(cascade --version | awk '{print $2}') | ||
| EXPECTED="${VERSION#cascade-v}" | ||
| if [ "$INSTALLED" != "$EXPECTED" ]; then | ||
| echo "version mismatch: installed=$INSTALLED expected=$EXPECTED" | ||
| exit 1 | ||
| fi | ||
| env: | ||
| VERSION: ${{ needs.release.outputs.version }} | ||
| verify-scoop: | ||
| name: Verify Scoop install (Windows) | ||
| needs: [release, update-scoop] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 15 | ||
| runs-on: windows-2025-vs2026 | ||
| steps: | ||
| - name: Cache Scoop downloads | ||
| # Caching the full ~/scoop dir restores an older cascade across | ||
| # releases, so `scoop install` becomes a no-op and the version | ||
| # check fails. Cache only the bottle download dir. | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ~\scoop\cache | ||
| key: scoop-downloads-${{ needs.release.outputs.version }} | ||
| restore-keys: scoop-downloads- | ||
| - name: Install Scoop | ||
| shell: pwsh | ||
| run: | | ||
| if (-not (Test-Path "$env:USERPROFILE\scoop\shims\scoop.ps1")) { | ||
| Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser | ||
| Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression | ||
| } | ||
| Add-Content $env:GITHUB_PATH "$env:USERPROFILE\scoop\shims" | ||
| - name: Add bucket and install cascade | ||
| shell: pwsh | ||
| run: | | ||
| if (-not (scoop bucket list | Select-String -Quiet '^cascade')) { | ||
| scoop bucket add cascade https://github.com/Mearman/scoop-cascade | ||
| } | ||
| scoop install cascade | ||
| - name: Run cascade --version | ||
| shell: pwsh | ||
| env: | ||
| VERSION: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| $output = cascade --version | ||
| Write-Host $output | ||
| $installed = ($output -split '\s+')[1] | ||
| $expected = $env:VERSION -replace '^cascade-v','' | ||
| if ($installed -ne $expected) { | ||
| Write-Host "version mismatch: installed=$installed expected=$expected" | ||
| exit 1 | ||
| } | ||
| smoke-macos: | ||
| name: Smoke test (macOS — daemon + mount) | ||
| needs: [release, update-tap] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 20 | ||
| runs-on: macos-latest | ||
| steps: | ||
| - name: Cache Homebrew downloads | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ~/Library/Caches/Homebrew/downloads | ||
| key: brew-cache-macos-${{ needs.release.outputs.version }} | ||
| restore-keys: brew-cache-macos- | ||
| - name: Tap Homebrew formula | ||
| run: brew tap Mearman/cascade | ||
| - name: Install via Homebrew | ||
| run: brew install Mearman/cascade/cascade | ||
| - name: Prepare smoke config (local backend) | ||
| run: | | ||
| set -eux | ||
| SMOKE="$RUNNER_TEMP/cascade-smoke" | ||
| mkdir -p "$SMOKE/config" "$SMOKE/storage" "$SMOKE/mount" | ||
| cat > "$SMOKE/config/config.toml" <<TOML | ||
| [backends.smoke] | ||
| type = "local" | ||
| root_path = "$SMOKE/storage" | ||
| [mount] | ||
| point = "$SMOKE/mount" | ||
| TOML | ||
| cat > "$SMOKE/config/smoke.toml" <<TOML | ||
| type = "local" | ||
| root_path = "$SMOKE/storage" | ||
| id = "smoke" | ||
| display_name = "Smoke" | ||
| TOML | ||
| echo "SMOKE=$SMOKE" >> "$GITHUB_ENV" | ||
| - name: Tier 2 — daemon up with --no-mount | ||
| run: | | ||
| set -eux | ||
| cascade --config "$SMOKE/config" start --no-mount > "$SMOKE/daemon.log" 2>&1 & | ||
| DAEMON=$! | ||
| echo "DAEMON=$DAEMON" >> "$GITHUB_ENV" | ||
| for i in 1 2 3 4 5 6 7 8 9 10; do | ||
| sleep 1 | ||
| cascade --config "$SMOKE/config" status 2>/dev/null | grep -q "Running: true" && break | ||
| done | ||
| cascade --config "$SMOKE/config" status | tee /tmp/status1.txt | ||
| grep -q "Running: true" /tmp/status1.txt | ||
| grep -q "smoke" /tmp/status1.txt | ||
| cascade --config "$SMOKE/config" stop | ||
| wait $DAEMON 2>/dev/null || true | ||
| - name: Tier 3 — actual WebDAV mount + file round-trip | ||
| run: | | ||
| set -eux | ||
| echo "pre-seeded" > "$SMOKE/storage/hello.txt" | ||
| cascade --config "$SMOKE/config" start > "$SMOKE/mount.log" 2>&1 & | ||
| DAEMON=$! | ||
| for i in $(seq 1 30); do | ||
| sleep 1 | ||
| mount | grep -q "$SMOKE/mount" && break | ||
| done | ||
| mount | grep "$SMOKE/mount" | ||
| ls -la "$SMOKE/mount/smoke/" | ||
| cat "$SMOKE/mount/smoke/hello.txt" | grep -q "pre-seeded" | ||
| cascade --config "$SMOKE/config" stop | ||
| wait $DAEMON 2>/dev/null || true | ||
| - name: Dump daemon log on failure | ||
| if: failure() | ||
| run: | | ||
| echo "=== daemon.log ==="; cat "$SMOKE/daemon.log" || true | ||
| echo "=== mount.log ==="; cat "$SMOKE/mount.log" || true | ||
| smoke-linux: | ||
| name: Smoke test (Linux — daemon + FUSE mount) | ||
| needs: [release, update-tap] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 20 | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Install FUSE | ||
| run: | | ||
| sudo apt-get update | ||
| sudo apt-get install -y fuse3 libfuse3-3 | ||
| - name: Cache Linuxbrew downloads | ||
| # See verify-brew-linux above: caching the prefix breaks the | ||
| # per-release install. | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ~/.cache/Homebrew/downloads | ||
| key: linuxbrew-downloads-${{ needs.release.outputs.version }} | ||
| restore-keys: linuxbrew-downloads- | ||
| - name: Install Homebrew on Linux | ||
| run: | | ||
| if ! command -v brew >/dev/null 2>&1 && ! [ -x /home/linuxbrew/.linuxbrew/bin/brew ]; then | ||
| /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" </dev/null | ||
| fi | ||
| echo "/home/linuxbrew/.linuxbrew/bin" >> "$GITHUB_PATH" | ||
| - name: Tap Homebrew formula | ||
| run: | | ||
| brew tap Mearman/cascade >/dev/null || true | ||
| test -d /home/linuxbrew/.linuxbrew/Homebrew/Library/Taps/mearman/homebrew-cascade | ||
| - name: Install via Homebrew | ||
| run: brew install Mearman/cascade/cascade | ||
| - name: Prepare smoke config (local backend) | ||
| run: | | ||
| set -eux | ||
| SMOKE="$RUNNER_TEMP/cascade-smoke" | ||
| mkdir -p "$SMOKE/config" "$SMOKE/storage" "$SMOKE/mount" | ||
| cat > "$SMOKE/config/config.toml" <<TOML | ||
| [backends.smoke] | ||
| type = "local" | ||
| root_path = "$SMOKE/storage" | ||
| [mount] | ||
| point = "$SMOKE/mount" | ||
| TOML | ||
| cat > "$SMOKE/config/smoke.toml" <<TOML | ||
| type = "local" | ||
| root_path = "$SMOKE/storage" | ||
| id = "smoke" | ||
| display_name = "Smoke" | ||
| TOML | ||
| echo "SMOKE=$SMOKE" >> "$GITHUB_ENV" | ||
| - name: Tier 2 — daemon up with --no-mount | ||
| run: | | ||
| set -eux | ||
| cascade --config "$SMOKE/config" start --no-mount > "$SMOKE/daemon.log" 2>&1 & | ||
| DAEMON=$! | ||
| for i in 1 2 3 4 5 6 7 8 9 10; do | ||
| sleep 1 | ||
| cascade --config "$SMOKE/config" status 2>/dev/null | grep -q "Running: true" && break | ||
| done | ||
| cascade --config "$SMOKE/config" status | tee /tmp/status1.txt | ||
| grep -q "Running: true" /tmp/status1.txt | ||
| grep -q "smoke" /tmp/status1.txt | ||
| cascade --config "$SMOKE/config" stop | ||
| wait $DAEMON 2>/dev/null || true | ||
| - name: Tier 3 — actual FUSE mount + file read | ||
| run: | | ||
| set -eux | ||
| echo "pre-seeded" > "$SMOKE/storage/hello.txt" | ||
| cascade --config "$SMOKE/config" start > "$SMOKE/mount.log" 2>&1 & | ||
| DAEMON=$! | ||
| for i in $(seq 1 30); do | ||
| sleep 1 | ||
| mount | grep -q "$SMOKE/mount" && break | ||
| done | ||
| mount | grep "$SMOKE/mount" || (echo "FUSE mount did not appear within 30s"; cat "$SMOKE/mount.log"; exit 1) | ||
| ls -la "$SMOKE/mount/" || true | ||
| cascade --config "$SMOKE/config" stop | ||
| wait $DAEMON 2>/dev/null || true | ||
| - name: Dump daemon log on failure | ||
| if: failure() | ||
| run: | | ||
| echo "=== daemon.log ==="; cat "$SMOKE/daemon.log" || true | ||
| echo "=== mount.log ==="; cat "$SMOKE/mount.log" || true | ||
| smoke-windows: | ||
| name: Smoke test (Windows — daemon + WebDAV mount) | ||
| needs: [release, update-scoop] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 20 | ||
| runs-on: windows-2025-vs2026 | ||
| steps: | ||
| - name: Cache Scoop downloads | ||
| # Caching the full ~/scoop dir restores an older cascade across | ||
| # releases, so `scoop install` becomes a no-op and the version | ||
| # check fails. Cache only the bottle download dir. | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ~\scoop\cache | ||
| key: scoop-downloads-${{ needs.release.outputs.version }} | ||
| restore-keys: scoop-downloads- | ||
| - name: Install Scoop | ||
| shell: pwsh | ||
| run: | | ||
| if (-not (Test-Path "$env:USERPROFILE\scoop\shims\scoop.ps1")) { | ||
| Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser | ||
| Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression | ||
| } | ||
| Add-Content $env:GITHUB_PATH "$env:USERPROFILE\scoop\shims" | ||
| - name: Add bucket and install cascade | ||
| shell: pwsh | ||
| run: | | ||
| if (-not (scoop bucket list | Select-String -Quiet '^cascade')) { | ||
| scoop bucket add cascade https://github.com/Mearman/scoop-cascade | ||
| } | ||
| scoop install cascade | ||
| - name: Note WebClient service availability | ||
| shell: pwsh | ||
| # windows-latest is Windows Server, which does not ship the | ||
| # WebClient service by default (it's part of Desktop Experience). | ||
| # The cascade-managed `net use` mount path therefore can't be | ||
| # exercised here. We instead verify the WebDAV server cascade | ||
| # runs in-process is reachable — the `net use` call itself is | ||
| # a thin wrapper around the Windows redirector that requires | ||
| # WebClient to work. | ||
| run: | | ||
| $svc = Get-Service -Name WebClient -ErrorAction SilentlyContinue | ||
| if ($null -eq $svc) { | ||
| Write-Host "WebClient service not installed on this runner — using HTTP probe instead of net use for tier 3." | ||
| } else { | ||
| Set-Service -Name WebClient -StartupType Automatic | ||
| Start-Service -Name WebClient | ||
| Get-Service -Name WebClient | ||
| } | ||
| - name: Prepare smoke config (local backend) | ||
| shell: pwsh | ||
| run: | | ||
| $smoke = "$env:RUNNER_TEMP\cascade-smoke" | ||
| $storage = "$smoke\storage" | ||
| $mount = "$smoke\mount" | ||
| $config = "$smoke\config" | ||
| New-Item -ItemType Directory -Force -Path $config, $storage, $mount | Out-Null | ||
| $storageFwd = $storage -replace '\\','/' | ||
| $mountFwd = $mount -replace '\\','/' | ||
| @" | ||
| [backends.smoke] | ||
| type = "local" | ||
| root_path = "$storageFwd" | ||
| [mount] | ||
| point = "$mountFwd" | ||
| "@ | Out-File -Encoding utf8 -NoNewline "$config\config.toml" | ||
| @" | ||
| type = "local" | ||
| root_path = "$storageFwd" | ||
| id = "smoke" | ||
| display_name = "Smoke" | ||
| "@ | Out-File -Encoding utf8 -NoNewline "$config\smoke.toml" | ||
| "SMOKE=$smoke" | Add-Content -Path $env:GITHUB_ENV | ||
| - name: Tier 2 — daemon up with --no-mount | ||
| shell: pwsh | ||
| run: | | ||
| $smoke = $env:SMOKE | ||
| $proc = Start-Process -FilePath cascade -ArgumentList "--config","$smoke\config","start","--no-mount" -PassThru -RedirectStandardOutput "$smoke\daemon.log" -RedirectStandardError "$smoke\daemon.err" | ||
| for ($i = 0; $i -lt 10; $i++) { | ||
| Start-Sleep -Seconds 1 | ||
| $status = (cascade --config "$smoke\config" status 2>$null) | ||
| if ($status -match "Running: true") { break } | ||
| } | ||
| $status = cascade --config "$smoke\config" status | ||
| Write-Host $status | ||
| if (-not ($status -match "Running: true")) { throw "daemon did not report running" } | ||
| if (-not ($status -match "smoke")) { throw "backend not registered" } | ||
| cascade --config "$smoke\config" stop | ||
| Wait-Process -Id $proc.Id -Timeout 10 -ErrorAction SilentlyContinue | ||
| - name: Tier 3 — WebDAV server reachable via HTTP | ||
| shell: pwsh | ||
| # We can't exercise `net use` without the WebClient service, but | ||
| # the WebDAV server cascade runs in-process is testable directly: | ||
| # PROPFIND on the root should return 207 Multi-Status with the | ||
| # registered backends as collections. | ||
| run: | | ||
| $smoke = $env:SMOKE | ||
| "pre-seeded" | Out-File -Encoding ascii -NoNewline "$smoke\storage\hello.txt" | ||
| $proc = Start-Process -FilePath cascade -ArgumentList "--config","$smoke\config","start","--no-mount" -PassThru -RedirectStandardOutput "$smoke\mount.log" -RedirectStandardError "$smoke\mount.err" | ||
| $port = $null | ||
| for ($i = 0; $i -lt 30; $i++) { | ||
| Start-Sleep -Seconds 1 | ||
| if (Test-Path "$smoke\mount.log") { | ||
| $log = Get-Content "$smoke\mount.log" -Raw | ||
| if ($log -match 'WebDAV server started.*port[= ]+(\d+)') { | ||
| $port = $matches[1]; break | ||
| } | ||
| if ($log -match 'WebDAV URL: http://localhost:(\d+)') { | ||
| $port = $matches[1]; break | ||
| } | ||
| } | ||
| } | ||
| if (-not $port) { | ||
| Write-Host (Get-Content "$smoke\mount.log" -ErrorAction SilentlyContinue) | ||
| throw "WebDAV server port not detected in daemon log" | ||
| } | ||
| Write-Host "WebDAV port: $port" | ||
| # Invoke-WebRequest only knows standard HTTP verbs; WebDAV's | ||
| # PROPFIND isn't in its WebRequestMethod enum. curl.exe ships | ||
| # with Windows 10+/Server 2019+ and accepts any verb. | ||
| $body = curl.exe -sS -X PROPFIND -H "Depth: 1" -w "%{http_code}" -o response.xml "http://localhost:$port/" | ||
| Write-Host "HTTP $body" | ||
| if ($body -ne '207') { throw "expected 207, got $body" } | ||
| $content = Get-Content response.xml -Raw | ||
| if ($content -notmatch 'smoke') { throw "registered backend not in PROPFIND response" } | ||
| Write-Host "PROPFIND returned 207 with backend present." | ||
| cascade --config "$smoke\config" stop | ||
| Wait-Process -Id $proc.Id -Timeout 10 -ErrorAction SilentlyContinue | ||
| - name: Dump daemon log on failure | ||
| if: failure() | ||
| shell: pwsh | ||
| run: | | ||
| $smoke = $env:SMOKE | ||
| if (Test-Path "$smoke\daemon.log") { Write-Host "=== daemon.log ==="; Get-Content "$smoke\daemon.log" } | ||
| if (Test-Path "$smoke\daemon.err") { Write-Host "=== daemon.err ==="; Get-Content "$smoke\daemon.err" } | ||
| if (Test-Path "$smoke\mount.log") { Write-Host "=== mount.log ==="; Get-Content "$smoke\mount.log" } | ||
| if (Test-Path "$smoke\mount.err") { Write-Host "=== mount.err ==="; Get-Content "$smoke\mount.err" } | ||
| # =========================================================================== | ||
| # Relay artifacts — built and attached to the SAME cascade release. | ||
| # | ||
| # Single-release model: the relay binaries + checksums become assets of the | ||
| # cascade-vX release (named cascade-relay-* so they never collide with the | ||
| # cascade-* assets), and the Docker image is pushed to | ||
| # ghcr.io/mearman/cascade-relay. Every job gates on the cascade release and | ||
| # uploads to the release release-plz already created — so there is no separate | ||
| # cascade-relay-vX tag and no create-before-upload race. needs.release.outputs | ||
| # .version is the cascade tag (e.g. cascade-v0.1.57); the bare version is | ||
| # derived where needed. | ||
| # =========================================================================== | ||
| docker-amd64: | ||
| name: Docker image (amd64) | ||
| needs: [release] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 30 | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| packages: write | ||
| outputs: | ||
| digest: ${{ steps.export.outputs.digest }} | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - name: Set up buildx | ||
| run: | | ||
| docker buildx create --use --name relay-builder --driver docker-container | ||
| docker buildx inspect --bootstrap | ||
| - name: Log in to ghcr.io | ||
| run: | | ||
| echo "${{ secrets.GITHUB_TOKEN }}" | \ | ||
| docker login ghcr.io --username "${{ github.actor }}" --password-stdin | ||
| - name: Build and push by digest | ||
| env: | ||
| TAG: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| VERSION="${TAG#cascade-v}" | ||
| docker buildx build \ | ||
| --platform linux/amd64 \ | ||
| --file crates/relay-server/Dockerfile \ | ||
| --label "org.opencontainers.image.source=https://github.com/Mearman/cascade" \ | ||
| --label "org.opencontainers.image.version=${VERSION}" \ | ||
| --label "org.opencontainers.image.revision=${{ github.sha }}" \ | ||
| --label "org.opencontainers.image.description=cascade-relay — opaque byte-pipe relay for Cascade peers behind NATs" \ | ||
| --label "org.opencontainers.image.licenses=MIT" \ | ||
| --cache-from "type=registry,ref=ghcr.io/${IMAGE_NAME}:buildcache-amd64" \ | ||
| --cache-to "type=registry,ref=ghcr.io/${IMAGE_NAME}:buildcache-amd64,mode=max" \ | ||
| --output "type=image,name=ghcr.io/${IMAGE_NAME},push-by-digest=true,name-canonical=true,push=true" \ | ||
| --metadata-file metadata.json \ | ||
| . | ||
| - name: Export digest | ||
| id: export | ||
| run: | | ||
| DIGEST=$(python3 -c "import json; print(json.load(open('metadata.json'))['containerimage.digest'])") | ||
| echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" | ||
| docker-arm64: | ||
| name: Docker image (arm64) | ||
| needs: [release] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 30 | ||
| runs-on: ubuntu-24.04-arm | ||
| permissions: | ||
| contents: read | ||
| packages: write | ||
| outputs: | ||
| digest: ${{ steps.export.outputs.digest }} | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - name: Set up buildx | ||
| run: | | ||
| docker buildx create --use --name relay-builder --driver docker-container | ||
| docker buildx inspect --bootstrap | ||
| - name: Log in to ghcr.io | ||
| run: | | ||
| echo "${{ secrets.GITHUB_TOKEN }}" | \ | ||
| docker login ghcr.io --username "${{ github.actor }}" --password-stdin | ||
| - name: Build and push by digest | ||
| env: | ||
| TAG: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| VERSION="${TAG#cascade-v}" | ||
| docker buildx build \ | ||
| --platform linux/arm64 \ | ||
| --file crates/relay-server/Dockerfile \ | ||
| --label "org.opencontainers.image.source=https://github.com/Mearman/cascade" \ | ||
| --label "org.opencontainers.image.version=${VERSION}" \ | ||
| --label "org.opencontainers.image.revision=${{ github.sha }}" \ | ||
| --label "org.opencontainers.image.description=cascade-relay — opaque byte-pipe relay for Cascade peers behind NATs" \ | ||
| --label "org.opencontainers.image.licenses=MIT" \ | ||
| --cache-from "type=registry,ref=ghcr.io/${IMAGE_NAME}:buildcache-arm64" \ | ||
| --cache-to "type=registry,ref=ghcr.io/${IMAGE_NAME}:buildcache-arm64,mode=max" \ | ||
| --output "type=image,name=ghcr.io/${IMAGE_NAME},push-by-digest=true,name-canonical=true,push=true" \ | ||
| --metadata-file metadata.json \ | ||
| . | ||
| - name: Export digest | ||
| id: export | ||
| run: | | ||
| DIGEST=$(python3 -c "import json; print(json.load(open('metadata.json'))['containerimage.digest'])") | ||
| echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" | ||
| docker-manifest: | ||
| name: Docker manifest (ghcr.io) | ||
| needs: [release, docker-amd64, docker-arm64] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 30 | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| packages: write | ||
| steps: | ||
| - name: Log in to ghcr.io | ||
| run: | | ||
| echo "${{ secrets.GITHUB_TOKEN }}" | \ | ||
| docker login ghcr.io --username "${{ github.actor }}" --password-stdin | ||
| - name: Create and push multi-arch manifest | ||
| env: | ||
| TAG: ${{ needs.release.outputs.version }} | ||
| DIGEST_AMD64: ${{ needs.docker-amd64.outputs.digest }} | ||
| DIGEST_ARM64: ${{ needs.docker-arm64.outputs.digest }} | ||
| run: | | ||
| VERSION="${TAG#cascade-v}" | ||
| docker buildx imagetools create \ | ||
| --tag "ghcr.io/${IMAGE_NAME}:${VERSION}" \ | ||
| --tag "ghcr.io/${IMAGE_NAME}:latest" \ | ||
| "ghcr.io/${IMAGE_NAME}@${DIGEST_AMD64}" \ | ||
| "ghcr.io/${IMAGE_NAME}@${DIGEST_ARM64}" | ||
| # =========================================================================== | ||
| # Daemon Docker image — multi-arch (linux/amd64 + linux/arm64). | ||
| # | ||
| # Mirrors docker-amd64 / docker-arm64 / docker-manifest above but targets | ||
| # ghcr.io/mearman/cascade (the cascade daemon) rather than the relay image. | ||
| # Per-job IMAGE_NAME overrides the workflow-level mearman/cascade-relay so | ||
| # the buildcache refs, manifest tags, and push targets do not collide. | ||
| # =========================================================================== | ||
| docker-daemon-amd64: | ||
| name: Docker daemon image (amd64) | ||
| needs: [release] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 60 | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| packages: write | ||
| env: | ||
| IMAGE_NAME: mearman/cascade | ||
| outputs: | ||
| digest: ${{ steps.export.outputs.digest }} | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - name: Set up buildx | ||
| run: | | ||
| docker buildx create --use --name daemon-builder --driver docker-container | ||
| docker buildx inspect --bootstrap | ||
| - name: Log in to ghcr.io | ||
| run: | | ||
| echo "${{ secrets.GITHUB_TOKEN }}" | \ | ||
| docker login ghcr.io --username "${{ github.actor }}" --password-stdin | ||
| - name: Build and push by digest | ||
| env: | ||
| TAG: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| VERSION="${TAG#cascade-v}" | ||
| docker buildx build \ | ||
| --platform linux/amd64 \ | ||
| --file crates/cascade/Dockerfile \ | ||
| --label "org.opencontainers.image.source=https://github.com/Mearman/cascade" \ | ||
| --label "org.opencontainers.image.version=${VERSION}" \ | ||
| --label "org.opencontainers.image.revision=${{ github.sha }}" \ | ||
| --label "org.opencontainers.image.description=cascade daemon — cross-platform cloud storage filesystem client" \ | ||
| --label "org.opencontainers.image.licenses=MIT" \ | ||
| --cache-from "type=registry,ref=ghcr.io/${IMAGE_NAME}:buildcache-cascade-amd64" \ | ||
| --cache-to "type=registry,ref=ghcr.io/${IMAGE_NAME}:buildcache-cascade-amd64,mode=max" \ | ||
| --output "type=image,name=ghcr.io/${IMAGE_NAME},push-by-digest=true,name-canonical=true,push=true" \ | ||
| --metadata-file metadata.json \ | ||
| . | ||
| - name: Export digest | ||
| id: export | ||
| run: | | ||
| DIGEST=$(python3 -c "import json; print(json.load(open('metadata.json'))['containerimage.digest'])") | ||
| echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" | ||
| docker-daemon-arm64: | ||
| name: Docker daemon image (arm64) | ||
| needs: [release] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 60 | ||
| runs-on: ubuntu-24.04-arm | ||
| permissions: | ||
| contents: read | ||
| packages: write | ||
| env: | ||
| IMAGE_NAME: mearman/cascade | ||
| outputs: | ||
| digest: ${{ steps.export.outputs.digest }} | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - name: Set up buildx | ||
| run: | | ||
| docker buildx create --use --name daemon-builder --driver docker-container | ||
| docker buildx inspect --bootstrap | ||
| - name: Log in to ghcr.io | ||
| run: | | ||
| echo "${{ secrets.GITHUB_TOKEN }}" | \ | ||
| docker login ghcr.io --username "${{ github.actor }}" --password-stdin | ||
| - name: Build and push by digest | ||
| env: | ||
| TAG: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| VERSION="${TAG#cascade-v}" | ||
| docker buildx build \ | ||
| --platform linux/arm64 \ | ||
| --file crates/cascade/Dockerfile \ | ||
| --label "org.opencontainers.image.source=https://github.com/Mearman/cascade" \ | ||
| --label "org.opencontainers.image.version=${VERSION}" \ | ||
| --label "org.opencontainers.image.revision=${{ github.sha }}" \ | ||
| --label "org.opencontainers.image.description=cascade daemon — cross-platform cloud storage filesystem client" \ | ||
| --label "org.opencontainers.image.licenses=MIT" \ | ||
| --cache-from "type=registry,ref=ghcr.io/${IMAGE_NAME}:buildcache-cascade-arm64" \ | ||
| --cache-to "type=registry,ref=ghcr.io/${IMAGE_NAME}:buildcache-cascade-arm64,mode=max" \ | ||
| --output "type=image,name=ghcr.io/${IMAGE_NAME},push-by-digest=true,name-canonical=true,push=true" \ | ||
| --metadata-file metadata.json \ | ||
| . | ||
| - name: Export digest | ||
| id: export | ||
| run: | | ||
| DIGEST=$(python3 -c "import json; print(json.load(open('metadata.json'))['containerimage.digest'])") | ||
| echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" | ||
| docker-daemon-manifest: | ||
| name: Docker daemon manifest (ghcr.io) | ||
| needs: [release, docker-daemon-amd64, docker-daemon-arm64] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 30 | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| packages: write | ||
| env: | ||
| IMAGE_NAME: mearman/cascade | ||
| steps: | ||
| - name: Log in to ghcr.io | ||
| run: | | ||
| echo "${{ secrets.GITHUB_TOKEN }}" | \ | ||
| docker login ghcr.io --username "${{ github.actor }}" --password-stdin | ||
| - name: Create and push multi-arch manifest | ||
| env: | ||
| TAG: ${{ needs.release.outputs.version }} | ||
| DIGEST_AMD64: ${{ needs.docker-daemon-amd64.outputs.digest }} | ||
| DIGEST_ARM64: ${{ needs.docker-daemon-arm64.outputs.digest }} | ||
| run: | | ||
| VERSION="${TAG#cascade-v}" | ||
| docker buildx imagetools create \ | ||
| --tag "ghcr.io/${IMAGE_NAME}:${VERSION}" \ | ||
| --tag "ghcr.io/${IMAGE_NAME}:latest" \ | ||
| "ghcr.io/${IMAGE_NAME}@${DIGEST_AMD64}" \ | ||
| "ghcr.io/${IMAGE_NAME}@${DIGEST_ARM64}" | ||
| build-relay-macos-arm: | ||
| name: Build relay macOS ARM | ||
| needs: [release] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 30 | ||
| runs-on: macos-latest | ||
| permissions: | ||
| contents: write | ||
| env: | ||
| RUSTC_WRAPPER: sccache | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - name: Install Rust toolchain | ||
| run: | | ||
| curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ | ||
| sh -s -- -y --default-toolchain stable --target aarch64-apple-darwin | ||
| echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" | ||
| - name: Install sccache | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: sccache | ||
| - name: Configure sccache dir | ||
| # runner.temp is not available in job-level env, so export SCCACHE_DIR | ||
| # here (bash is present on every runner OS, so this is uniform across | ||
| # Linux/macOS/Windows). It matches the actions/cache path below. | ||
| shell: bash | ||
| run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache" >> "$GITHUB_ENV" | ||
| - name: Restore sccache | ||
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-relay-aarch64-apple-darwin-${{ github.sha }} | ||
| restore-keys: sccache-relay-aarch64-apple-darwin- | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: relay-aarch64-apple-darwin | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Build | ||
| # Retry on transient cargo registry network failures. The last | ||
| # attempt's exit code propagates, so permanent build errors | ||
| # still fail the job. | ||
| shell: bash | ||
| run: | | ||
| status=1 | ||
| for i in 1 2 3; do | ||
| if cargo build --release --target aarch64-apple-darwin -p cascade-relay-server; then | ||
| status=0 | ||
| break | ||
| fi | ||
| echo "::warning::Build attempt $i failed, retrying..." | ||
| sleep 10 | ||
| done | ||
| exit $status | ||
| - name: sccache stats | ||
| run: sccache --show-stats | ||
| - name: Save sccache | ||
| if: github.ref == 'refs/heads/main' | ||
| # sccache is a build-time accelerator; an empty or failed cache save | ||
| # is a noisy warning, not a real failure. Continue on error so the | ||
| # post-build upload doesn't pollute the run summary. | ||
| continue-on-error: true | ||
| uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-relay-aarch64-apple-darwin-${{ github.sha }} | ||
| - name: Package archive | ||
| run: | | ||
| mkdir -p dist | ||
| cp target/aarch64-apple-darwin/release/cascade-relay dist/cascade-relay | ||
| cd dist | ||
| tar czf cascade-relay-aarch64-macos.tar.gz cascade-relay | ||
| shasum -a 256 cascade-relay-aarch64-macos.tar.gz | awk '{print $1}' \ | ||
| > cascade-relay-aarch64-macos.tar.gz.sha256 | ||
| - name: Upload to release | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| TAG: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| gh release upload "$TAG" \ | ||
| dist/cascade-relay-aarch64-macos.tar.gz \ | ||
| dist/cascade-relay-aarch64-macos.tar.gz.sha256 \ | ||
| --repo Mearman/cascade \ | ||
| --clobber | ||
| build-relay-macos-x86: | ||
| name: Build relay macOS x86_64 | ||
| needs: [release] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 30 | ||
| runs-on: macos-latest | ||
| permissions: | ||
| contents: write | ||
| env: | ||
| RUSTC_WRAPPER: sccache | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - name: Install Rust toolchain | ||
| run: | | ||
| curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ | ||
| sh -s -- -y --default-toolchain stable --target x86_64-apple-darwin | ||
| echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" | ||
| rustup target add x86_64-apple-darwin | ||
| - name: Install sccache | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: sccache | ||
| - name: Configure sccache dir | ||
| # runner.temp is not available in job-level env, so export SCCACHE_DIR | ||
| # here (bash is present on every runner OS, so this is uniform across | ||
| # Linux/macOS/Windows). It matches the actions/cache path below. | ||
| shell: bash | ||
| run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache" >> "$GITHUB_ENV" | ||
| - name: Restore sccache | ||
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-relay-x86_64-apple-darwin-${{ github.sha }} | ||
| restore-keys: sccache-relay-x86_64-apple-darwin- | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: relay-x86_64-apple-darwin | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Build | ||
| # Retry on transient cargo registry network failures. The last | ||
| # attempt's exit code propagates, so permanent build errors | ||
| # still fail the job. | ||
| shell: bash | ||
| run: | | ||
| status=1 | ||
| for i in 1 2 3; do | ||
| if cargo build --release --target x86_64-apple-darwin -p cascade-relay-server; then | ||
| status=0 | ||
| break | ||
| fi | ||
| echo "::warning::Build attempt $i failed, retrying..." | ||
| sleep 10 | ||
| done | ||
| exit $status | ||
| - name: sccache stats | ||
| run: sccache --show-stats | ||
| - name: Save sccache | ||
| if: github.ref == 'refs/heads/main' | ||
| # sccache is a build-time accelerator; an empty or failed cache save | ||
| # is a noisy warning, not a real failure. Continue on error so the | ||
| # post-build upload doesn't pollute the run summary. | ||
| continue-on-error: true | ||
| uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-relay-x86_64-apple-darwin-${{ github.sha }} | ||
| - name: Package archive | ||
| run: | | ||
| mkdir -p dist | ||
| cp target/x86_64-apple-darwin/release/cascade-relay dist/cascade-relay | ||
| cd dist | ||
| tar czf cascade-relay-x86_64-macos.tar.gz cascade-relay | ||
| shasum -a 256 cascade-relay-x86_64-macos.tar.gz | awk '{print $1}' \ | ||
| > cascade-relay-x86_64-macos.tar.gz.sha256 | ||
| - name: Upload to release | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| RELAY_TAG: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| gh release upload "$RELAY_TAG" \ | ||
| dist/cascade-relay-x86_64-macos.tar.gz \ | ||
| dist/cascade-relay-x86_64-macos.tar.gz.sha256 \ | ||
| --repo Mearman/cascade \ | ||
| --clobber | ||
| build-relay-linux-x86: | ||
| name: Build relay Linux x86_64 | ||
| needs: [release] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 30 | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: write | ||
| env: | ||
| RUSTC_WRAPPER: sccache | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - name: Install Rust toolchain | ||
| run: | | ||
| curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ | ||
| sh -s -- -y --default-toolchain stable | ||
| echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" | ||
| - name: Install sccache | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: sccache | ||
| - name: Configure sccache dir | ||
| # runner.temp is not available in job-level env, so export SCCACHE_DIR | ||
| # here (bash is present on every runner OS, so this is uniform across | ||
| # Linux/macOS/Windows). It matches the actions/cache path below. | ||
| shell: bash | ||
| run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache" >> "$GITHUB_ENV" | ||
| - name: Restore sccache | ||
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-relay-x86_64-unknown-linux-gnu-${{ github.sha }} | ||
| restore-keys: sccache-relay-x86_64-unknown-linux-gnu- | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: relay-x86_64-unknown-linux-gnu | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Install system dependencies | ||
| run: sudo apt-get update && sudo apt-get install -y libfuse3-dev | ||
| - name: Build | ||
| # Retry on transient cargo registry network failures. The last | ||
| # attempt's exit code propagates, so permanent build errors | ||
| # still fail the job. | ||
| shell: bash | ||
| run: | | ||
| status=1 | ||
| for i in 1 2 3; do | ||
| if cargo build --release --target x86_64-unknown-linux-gnu -p cascade-relay-server; then | ||
| status=0 | ||
| break | ||
| fi | ||
| echo "::warning::Build attempt $i failed, retrying..." | ||
| sleep 10 | ||
| done | ||
| exit $status | ||
| - name: sccache stats | ||
| run: sccache --show-stats | ||
| - name: Save sccache | ||
| if: github.ref == 'refs/heads/main' | ||
| # sccache is a build-time accelerator; an empty or failed cache save | ||
| # is a noisy warning, not a real failure. Continue on error so the | ||
| # post-build upload doesn't pollute the run summary. | ||
| continue-on-error: true | ||
| uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-relay-x86_64-unknown-linux-gnu-${{ github.sha }} | ||
| - name: Package archive | ||
| run: | | ||
| mkdir -p dist | ||
| cp target/x86_64-unknown-linux-gnu/release/cascade-relay dist/cascade-relay | ||
| cd dist | ||
| tar czf cascade-relay-x86_64-linux.tar.gz cascade-relay | ||
| sha256sum cascade-relay-x86_64-linux.tar.gz | awk '{print $1}' \ | ||
| > cascade-relay-x86_64-linux.tar.gz.sha256 | ||
| - name: Upload to release | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| RELAY_TAG: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| gh release upload "$RELAY_TAG" \ | ||
| dist/cascade-relay-x86_64-linux.tar.gz \ | ||
| dist/cascade-relay-x86_64-linux.tar.gz.sha256 \ | ||
| --repo Mearman/cascade \ | ||
| --clobber | ||
| build-relay-linux-arm: | ||
| name: Build relay Linux ARM64 | ||
| needs: [release] | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 30 | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: write | ||
| env: | ||
| RUSTC_WRAPPER: sccache | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| ref: ${{ needs.release.outputs.version }} | ||
| - name: Install Rust toolchain and cross target | ||
| run: | | ||
| curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ | ||
| sh -s -- -y --default-toolchain stable | ||
| echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" | ||
| rustup target add aarch64-unknown-linux-gnu | ||
| - name: Install sccache | ||
| uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 | ||
| with: | ||
| tool: sccache | ||
| - name: Configure sccache dir | ||
| # runner.temp is not available in job-level env, so export SCCACHE_DIR | ||
| # here (bash is present on every runner OS, so this is uniform across | ||
| # Linux/macOS/Windows). It matches the actions/cache path below. | ||
| shell: bash | ||
| run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache" >> "$GITHUB_ENV" | ||
| - name: Restore sccache | ||
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-relay-aarch64-unknown-linux-gnu-${{ github.sha }} | ||
| restore-keys: sccache-relay-aarch64-unknown-linux-gnu- | ||
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | ||
| with: | ||
| key: relay-aarch64-unknown-linux-gnu | ||
| save-if: ${{ github.ref == 'refs/heads/main' }} | ||
| - name: Install cross-compilation toolchain | ||
| run: | | ||
| sudo apt-get update | ||
| sudo apt-get install -y gcc-aarch64-linux-gnu libfuse3-dev | ||
| - name: Build | ||
| # Retry on transient cargo registry network failures. The last | ||
| # attempt's exit code propagates, so permanent build errors | ||
| # still fail the job. | ||
| shell: bash | ||
| run: | | ||
| status=1 | ||
| for i in 1 2 3; do | ||
| if cargo build --release --target aarch64-unknown-linux-gnu -p cascade-relay-server; then | ||
| status=0 | ||
| break | ||
| fi | ||
| echo "::warning::Build attempt $i failed, retrying..." | ||
| sleep 10 | ||
| done | ||
| exit $status | ||
| env: | ||
| CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc | ||
| - name: sccache stats | ||
| run: sccache --show-stats | ||
| - name: Save sccache | ||
| if: github.ref == 'refs/heads/main' | ||
| # sccache is a build-time accelerator; an empty or failed cache save | ||
| # is a noisy warning, not a real failure. Continue on error so the | ||
| # post-build upload doesn't pollute the run summary. | ||
| continue-on-error: true | ||
| uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: ${{ runner.temp }}/sccache | ||
| key: sccache-relay-aarch64-unknown-linux-gnu-${{ github.sha }} | ||
| - name: Package archive | ||
| run: | | ||
| mkdir -p dist | ||
| cp target/aarch64-unknown-linux-gnu/release/cascade-relay dist/cascade-relay | ||
| cd dist | ||
| tar czf cascade-relay-aarch64-linux.tar.gz cascade-relay | ||
| sha256sum cascade-relay-aarch64-linux.tar.gz | awk '{print $1}' \ | ||
| > cascade-relay-aarch64-linux.tar.gz.sha256 | ||
| - name: Upload to release | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| RELAY_TAG: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| gh release upload "$RELAY_TAG" \ | ||
| dist/cascade-relay-aarch64-linux.tar.gz \ | ||
| dist/cascade-relay-aarch64-linux.tar.gz.sha256 \ | ||
| --repo Mearman/cascade \ | ||
| --clobber | ||
| update-tap-relay: | ||
| name: Update Homebrew tap (relay) | ||
| needs: | ||
| - release | ||
| - build-relay-macos-arm | ||
| - build-relay-macos-x86 | ||
| - build-relay-linux-x86 | ||
| - build-relay-linux-arm | ||
| if: needs.release.outputs.released == 'true' | ||
| timeout-minutes: 10 | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout tap via SSH deploy key | ||
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| repository: Mearman/homebrew-cascade | ||
| ssh-key: ${{ secrets.TAP_DEPLOY_KEY }} | ||
| - name: Download checksums | ||
| env: | ||
| RELAY_TAG: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| TAG_URL="https://github.com/Mearman/cascade/releases/download/${RELAY_TAG}" | ||
| fetch_sha() { | ||
| local name="$1" | ||
| local out="$2" | ||
| curl -sL --fail --retry 6 --retry-delay 5 --retry-all-errors \ | ||
| "${TAG_URL}/${name}.sha256" | awk '{print $1}' > "${out}" | ||
| if ! [[ "$(cat "${out}")" =~ ^[0-9a-f]{64}$ ]]; then | ||
| echo "::error::checksum for ${name} not a 64-char hex digest: $(cat "${out}")" | ||
| exit 1 | ||
| fi | ||
| } | ||
| fetch_sha cascade-relay-aarch64-macos.tar.gz sha-macos-arm | ||
| fetch_sha cascade-relay-x86_64-macos.tar.gz sha-macos-x86 | ||
| fetch_sha cascade-relay-x86_64-linux.tar.gz sha-linux-x86 | ||
| fetch_sha cascade-relay-aarch64-linux.tar.gz sha-linux-arm | ||
| - name: Generate formula | ||
| env: | ||
| RELAY_TAG: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| VERSION="${RELAY_TAG#cascade-v}" | ||
| MACOS_ARM_SHA=$(cat sha-macos-arm) | ||
| MACOS_X86_SHA=$(cat sha-macos-x86) | ||
| LINUX_X86_SHA=$(cat sha-linux-x86) | ||
| LINUX_ARM_SHA=$(cat sha-linux-arm) | ||
| mkdir -p Formula | ||
| cat > Formula/cascade-relay.rb <<EOF | ||
| # Auto-generated by CI — do not edit manually. | ||
| # Regenerated on each semantic release from Mearman/cascade. | ||
| class CascadeRelay < Formula | ||
| desc "Opaque byte-pipe relay server for Cascade peers behind NATs" | ||
| homepage "https://github.com/Mearman/cascade" | ||
| version "${VERSION}" | ||
| license "MIT" | ||
| on_macos do | ||
| if Hardware::CPU.arm? | ||
| url "https://github.com/Mearman/cascade/releases/download/${RELAY_TAG}/cascade-relay-aarch64-macos.tar.gz" | ||
| sha256 "${MACOS_ARM_SHA}" | ||
| else | ||
| url "https://github.com/Mearman/cascade/releases/download/${RELAY_TAG}/cascade-relay-x86_64-macos.tar.gz" | ||
| sha256 "${MACOS_X86_SHA}" | ||
| end | ||
| end | ||
| on_linux do | ||
| if Hardware::CPU.arm? | ||
| url "https://github.com/Mearman/cascade/releases/download/${RELAY_TAG}/cascade-relay-aarch64-linux.tar.gz" | ||
| sha256 "${LINUX_ARM_SHA}" | ||
| else | ||
| url "https://github.com/Mearman/cascade/releases/download/${RELAY_TAG}/cascade-relay-x86_64-linux.tar.gz" | ||
| sha256 "${LINUX_X86_SHA}" | ||
| end | ||
| end | ||
| def install | ||
| bin.install "cascade-relay" | ||
| end | ||
| test do | ||
| assert_match version.to_s, shell_output("#{bin}/cascade-relay --version") | ||
| end | ||
| end | ||
| EOF | ||
| - name: Commit and push | ||
| env: | ||
| RELAY_TAG: ${{ needs.release.outputs.version }} | ||
| run: | | ||
| git config user.name "github-actions[bot]" | ||
| git config user.email "github-actions[bot]@users.noreply.github.com" | ||
| git add Formula/cascade-relay.rb | ||
| git diff --cached --quiet || git commit -m "chore: update cascade-relay formula to ${RELAY_TAG}" | ||
| git push | ||
| # ── GitHub Pages (was .github/workflows/pages.yml) ────────────────── | ||
| pages-build: | ||
| name: Build PWA for Pages | ||
| needs: [changes] | ||
| if: needs.changes.outputs.web == 'true' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch') | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| - name: Setup Node 22 | ||
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | ||
| with: | ||
| node-version: '22' | ||
| - name: Enable pnpm | ||
| run: corepack enable | ||
| - name: Install Rust toolchain | ||
| uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | ||
| with: | ||
| toolchain: "1.96.0" | ||
| components: rustfmt, clippy | ||
| - name: Add wasm32 target | ||
| run: rustup target add wasm32-unknown-unknown | ||
| - name: Cache Cargo output | ||
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | ||
| with: | ||
| path: | | ||
| ~/.cargo/registry | ||
| target/wasm32-unknown-unknown | ||
| key: wasm32-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} | ||
| restore-keys: wasm32-${{ runner.os }}- | ||
| - name: Install wasm-bindgen CLI | ||
| run: | | ||
| VERSION=$(grep '^name = "wasm-bindgen"$' Cargo.lock -A1 | grep '^version' | head -1 | sed 's/.*"\(.*\)"/\1/') | ||
| cargo install wasm-bindgen-cli --version "$VERSION" | ||
| - name: Build WASM module | ||
| run: bash scripts/build-wasm.sh | ||
| - name: Install dependencies | ||
| working-directory: apps/web | ||
| run: pnpm install --frozen-lockfile | ||
| - name: Build | ||
| working-directory: apps/web | ||
| run: pnpm build | ||
| - name: Upload Pages artifact | ||
| uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 | ||
| with: | ||
| path: apps/web/dist | ||
| pages-deploy: | ||
| name: Deploy to GitHub Pages | ||
| needs: [pages-build] | ||
| if: github.ref == 'refs/heads/main' | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| pages: write | ||
| id-token: write | ||
| environment: | ||
| name: github-pages | ||
| url: ${{ steps.deploy.outputs.page_url }} | ||
| steps: | ||
| - name: Deploy to GitHub Pages | ||
| id: deploy | ||
| uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 | ||