Auto-tag releases from main when version bumps #16
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: Release | |
| on: | |
| push: | |
| branches: [main] | |
| tags: | |
| - "release-[0-9]+.[0-9]+.[0-9]+*" | |
| permissions: | |
| contents: write | |
| jobs: | |
| prepare: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.decide.outputs.version }} | |
| tag: ${{ steps.decide.outputs.tag }} | |
| should_release: ${{ steps.decide.outputs.should_release }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Determine version and tag | |
| id: decide | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| extract_toml_version() { | |
| # Read the first `version = "X.Y.Z"` line from the [package] table. | |
| awk -F'"' '/^version = /{print $2; exit}' "$1" | |
| } | |
| DESKTOP_VER=$(extract_toml_version src-tauri/Cargo.toml) | |
| CLI_VER=$(extract_toml_version crates/ralph-cli/Cargo.toml) | |
| CONF_VER=$(python3 -c 'import json; print(json.load(open("src-tauri/tauri.conf.json"))["version"])') | |
| PKG_VER=$(python3 -c 'import json; print(json.load(open("package.json"))["version"])') | |
| # src-tauri/Cargo.toml is canonical. Every other version string must | |
| # agree, otherwise the built artifacts would misrepresent themselves | |
| # and the updater's latest.json would disagree with the installed | |
| # binary's reported version. | |
| fail=0 | |
| for pair in "src-tauri/Cargo.toml=$DESKTOP_VER" \ | |
| "crates/ralph-cli/Cargo.toml=$CLI_VER" \ | |
| "src-tauri/tauri.conf.json=$CONF_VER" \ | |
| "package.json=$PKG_VER"; do | |
| file="${pair%%=*}" | |
| ver="${pair#*=}" | |
| if [[ "$ver" != "$DESKTOP_VER" ]]; then | |
| echo "::error file=$file::version mismatch: $file is '$ver', expected '$DESKTOP_VER' (from src-tauri/Cargo.toml)" | |
| fail=1 | |
| fi | |
| done | |
| if [[ "$fail" -eq 1 ]]; then | |
| exit 1 | |
| fi | |
| VERSION="$DESKTOP_VER" | |
| TAG="release-${VERSION}" | |
| if [[ "$GITHUB_REF" == refs/tags/release-* ]]; then | |
| # Direct tag push — build + release unconditionally. | |
| SHOULD_RELEASE=true | |
| else | |
| # Push to main — only release if this version hasn't been tagged. | |
| if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then | |
| echo "Tag $TAG already exists — version unchanged, skipping release" | |
| SHOULD_RELEASE=false | |
| else | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git tag -a "$TAG" -m "Release $VERSION" | |
| git push origin "$TAG" | |
| SHOULD_RELEASE=true | |
| fi | |
| fi | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "tag=$TAG" >> "$GITHUB_OUTPUT" | |
| echo "should_release=$SHOULD_RELEASE" >> "$GITHUB_OUTPUT" | |
| build-cli: | |
| needs: prepare | |
| if: needs.prepare.outputs.should_release == 'true' | |
| strategy: | |
| matrix: | |
| include: | |
| - target: aarch64-apple-darwin | |
| os: macos-latest | |
| artifact: ralph-macos-arm64 | |
| - target: x86_64-apple-darwin | |
| os: macos-latest | |
| artifact: ralph-macos-x86_64 | |
| - target: x86_64-pc-windows-msvc | |
| os: windows-latest | |
| artifact: ralph-windows-x86_64 | |
| - target: aarch64-pc-windows-msvc | |
| os: windows-latest | |
| artifact: ralph-windows-arm64 | |
| - target: x86_64-unknown-linux-gnu | |
| os: ubuntu-latest | |
| artifact: ralph-linux-x86_64 | |
| - target: aarch64-unknown-linux-gnu | |
| os: ubuntu-latest | |
| artifact: ralph-linux-arm64 | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.target }} | |
| - name: Install cross-compilation tools (Linux ARM64) | |
| if: matrix.target == 'aarch64-unknown-linux-gnu' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y gcc-aarch64-linux-gnu | |
| echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV | |
| - name: Build | |
| run: cargo build --release --target ${{ matrix.target }} -p ralph-cli | |
| - name: Prepare artifact (Unix) | |
| if: runner.os != 'Windows' | |
| run: | | |
| cp target/${{ matrix.target }}/release/ralph ${{ matrix.artifact }} | |
| chmod +x ${{ matrix.artifact }} | |
| - name: Prepare artifact (Windows) | |
| if: runner.os == 'Windows' | |
| run: cp target/${{ matrix.target }}/release/ralph.exe ${{ matrix.artifact }}.exe | |
| - name: Sign and notarize (macOS) | |
| if: runner.os == 'macOS' | |
| env: | |
| APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} | |
| APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} | |
| APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} | |
| APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} | |
| APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} | |
| APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} | |
| ARTIFACT: ${{ matrix.artifact }} | |
| run: | | |
| set -euo pipefail | |
| # Create a temporary keychain and import the signing certificate | |
| KEYCHAIN_PATH="$RUNNER_TEMP/signing.keychain-db" | |
| KEYCHAIN_PASSWORD=$(uuidgen) | |
| security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" | |
| security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| CERT_PATH="$RUNNER_TEMP/cert.p12" | |
| echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH" | |
| security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign | |
| security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') | |
| # Sign the binary with hardened runtime and a secure timestamp | |
| codesign --force --options runtime --timestamp \ | |
| --sign "$APPLE_SIGNING_IDENTITY" "$ARTIFACT" | |
| codesign --verify --verbose "$ARTIFACT" | |
| # Notarize: zip the binary and submit to the notary service. | |
| # Bare binaries can't be stapled, so Gatekeeper does an online check | |
| # on first run instead. | |
| export APPLE_API_KEY_PATH="$RUNNER_TEMP/AuthKey.p8" | |
| echo "$APPLE_API_KEY_BASE64" | base64 --decode > "$APPLE_API_KEY_PATH" | |
| ZIP_PATH="$RUNNER_TEMP/$ARTIFACT.zip" | |
| /usr/bin/ditto -c -k --keepParent "$ARTIFACT" "$ZIP_PATH" | |
| ./scripts/notarize.sh "$ZIP_PATH" | |
| # Cleanup | |
| security delete-keychain "$KEYCHAIN_PATH" | |
| rm -f "$CERT_PATH" "$APPLE_API_KEY_PATH" "$ZIP_PATH" | |
| - name: Upload artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ matrix.artifact }} | |
| path: ${{ matrix.artifact }}${{ runner.os == 'Windows' && '.exe' || '' }} | |
| build-desktop: | |
| needs: prepare | |
| if: needs.prepare.outputs.should_release == 'true' | |
| strategy: | |
| matrix: | |
| include: | |
| - os: macos-latest | |
| target: aarch64-apple-darwin | |
| artifact: ralph-desktop-macos-arm64 | |
| updater_platform: darwin-aarch64 | |
| - os: windows-latest | |
| target: x86_64-pc-windows-msvc | |
| artifact: ralph-desktop-windows-x86_64 | |
| updater_platform: windows-x86_64 | |
| - os: ubuntu-latest | |
| target: x86_64-unknown-linux-gnu | |
| artifact: ralph-desktop-linux-x86_64 | |
| updater_platform: linux-x86_64 | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.target }} | |
| - name: Install Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| - name: Install frontend dependencies | |
| run: npm ci | |
| - name: Install Linux dependencies | |
| if: runner.os == 'Linux' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf | |
| - name: Build Tauri app | |
| id: tauri | |
| uses: tauri-apps/tauri-action@v0 | |
| env: | |
| # Signing only — notarization is done manually in the next step | |
| # because Tauri's built-in notarization uses `notarytool --wait` | |
| # which hangs on transient network errors in CI. | |
| APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} | |
| APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} | |
| APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} | |
| KEYCHAIN_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} | |
| # Updater signing — required to produce .sig files and latest.json. | |
| TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} | |
| with: | |
| args: --target ${{ matrix.target }} | |
| includeUpdaterJson: true | |
| - name: Notarize and staple DMG (macOS) | |
| if: runner.os == 'macOS' | |
| env: | |
| APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} | |
| APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} | |
| APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} | |
| run: | | |
| set -euo pipefail | |
| export APPLE_API_KEY_PATH="$RUNNER_TEMP/AuthKey.p8" | |
| echo "$APPLE_API_KEY_BASE64" | base64 --decode > "$APPLE_API_KEY_PATH" | |
| DMG=$(ls target/${{ matrix.target }}/release/bundle/dmg/*.dmg | head -n1) | |
| echo "Notarizing $DMG" | |
| ./scripts/notarize.sh "$DMG" | |
| xcrun stapler staple "$DMG" | |
| xcrun stapler validate "$DMG" | |
| rm -f "$APPLE_API_KEY_PATH" | |
| - name: Collect updater metadata | |
| shell: bash | |
| env: | |
| TAG_NAME: ${{ needs.prepare.outputs.tag }} | |
| VERSION: ${{ needs.prepare.outputs.version }} | |
| BUNDLE_DIR: target/${{ matrix.target }}/release/bundle | |
| UPDATER_PLATFORM: ${{ matrix.updater_platform }} | |
| run: | | |
| set -euo pipefail | |
| # Find the updater bundle + its .sig produced by Tauri. Each OS | |
| # has exactly one updater target: .app.tar.gz (mac), .nsis .exe | |
| # (windows), .AppImage (linux). | |
| case "$UPDATER_PLATFORM" in | |
| darwin-*) | |
| BUNDLE=$(ls "$BUNDLE_DIR"/macos/*.app.tar.gz | head -n1) | |
| ;; | |
| windows-*) | |
| BUNDLE=$(ls "$BUNDLE_DIR"/nsis/*-setup.exe | head -n1) | |
| ;; | |
| linux-*) | |
| BUNDLE=$(ls "$BUNDLE_DIR"/appimage/*.AppImage | head -n1) | |
| ;; | |
| esac | |
| SIG_FILE="${BUNDLE}.sig" | |
| SIG=$(cat "$SIG_FILE") | |
| ASSET_NAME=$(basename "$BUNDLE") | |
| URL="https://github.com/KitStream/ralph/releases/download/${TAG_NAME}/${ASSET_NAME}" | |
| mkdir -p updater-meta | |
| cat > "updater-meta/${UPDATER_PLATFORM}.json" <<EOF | |
| { | |
| "platform": "${UPDATER_PLATFORM}", | |
| "version": "${VERSION}", | |
| "signature": "${SIG//$'\n'/\\n}", | |
| "url": "${URL}", | |
| "bundle": "${BUNDLE}" | |
| } | |
| EOF | |
| - name: Upload desktop artifacts | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ matrix.artifact }} | |
| path: | | |
| target/${{ matrix.target }}/release/bundle/dmg/*.dmg | |
| target/${{ matrix.target }}/release/bundle/nsis/*.exe | |
| target/${{ matrix.target }}/release/bundle/nsis/*.exe.sig | |
| target/${{ matrix.target }}/release/bundle/msi/*.msi | |
| target/${{ matrix.target }}/release/bundle/msi/*.msi.sig | |
| target/${{ matrix.target }}/release/bundle/deb/*.deb | |
| target/${{ matrix.target }}/release/bundle/appimage/*.AppImage | |
| target/${{ matrix.target }}/release/bundle/appimage/*.AppImage.sig | |
| target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz | |
| target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig | |
| - name: Upload updater metadata | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: updater-meta-${{ matrix.updater_platform }} | |
| path: updater-meta/*.json | |
| release: | |
| needs: [prepare, build-cli, build-desktop] | |
| if: needs.prepare.outputs.should_release == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Download all artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: artifacts | |
| - name: Assemble updater latest.json | |
| env: | |
| VERSION: ${{ needs.prepare.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") | |
| python3 - <<PY | |
| import json, glob, os | |
| platforms = {} | |
| version = os.environ["VERSION"] | |
| for path in glob.glob("artifacts/updater-meta-*/*.json"): | |
| with open(path) as f: | |
| meta = json.load(f) | |
| platforms[meta["platform"]] = { | |
| "signature": meta["signature"], | |
| "url": meta["url"], | |
| } | |
| out = { | |
| "version": version, | |
| "notes": "See release notes on GitHub.", | |
| "pub_date": "$PUB_DATE", | |
| "platforms": platforms, | |
| } | |
| os.makedirs("latest", exist_ok=True) | |
| with open("latest/latest.json", "w") as f: | |
| json.dump(out, f, indent=2) | |
| print(json.dumps(out, indent=2)) | |
| PY | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ needs.prepare.outputs.tag }} | |
| name: v${{ needs.prepare.outputs.version }} | |
| generate_release_notes: true | |
| body: | | |
| ## macOS notes | |
| The desktop app (`.dmg`) and CLI binaries are signed and notarized | |
| with a Developer ID. macOS will check Apple's notary service | |
| online on first launch — no manual `xattr` workaround needed. | |
| files: | | |
| artifacts/ralph-*/**/* | |
| latest/latest.json |