name: Release on: push: tags: - 'v*' workflow_dispatch: inputs: version: description: 'Version to release (e.g., 0.1.0)' required: true type: string dry_run: description: 'Dry run (skip publishing)' required: false default: false type: boolean env: CARGO_INCREMENTAL: 0 RUST_BACKTRACE: short permissions: contents: write jobs: create-release: runs-on: ubuntu-latest outputs: release_id: ${{ steps.create_release.outputs.id }} release_upload_url: ${{ steps.create_release.outputs.upload_url }} version: ${{ steps.get_version.outputs.version }} steps: - uses: actions/checkout@v4 - name: Get version id: get_version run: | if [[ -n "${{ github.event.inputs.version }}" ]]; then echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT elif [[ "${{ github.ref }}" == refs/tags/* ]]; then echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT else echo "version=0.0.0-dev" >> $GITHUB_OUTPUT fi - name: Extract changelog for release env: VERSION: ${{ steps.get_version.outputs.version }} run: | node - <<'NODE' const fs = require('fs'); const version = process.env.VERSION; const changelogPath = 'CHANGELOG.md'; if (!fs.existsSync(changelogPath)) { throw new Error('CHANGELOG.md not found; add it before releasing.'); } const changelog = fs.readFileSync(changelogPath, 'utf8'); const sections = changelog.split(/^## /m); const section = sections.find(s => s.startsWith('[' + version + ']')); if (!section) { throw new Error('Changelog section [' + version + '] not found. Add a section like "## [' + version + '] - YYYY-MM-DD".'); } const content = ('## ' + section).trim(); fs.mkdirSync('artifacts', { recursive: true }); fs.writeFileSync('artifacts/release-notes.md', content + '\n'); NODE - name: Create GitHub Release id: create_release uses: softprops/action-gh-release@v2 with: tag_name: v${{ steps.get_version.outputs.version }} draft: true generate_release_notes: false body_path: artifacts/release-notes.md name: OpenChamber v${{ steps.get_version.outputs.version }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} build-desktop-macos: needs: create-release runs-on: macos-26 strategy: fail-fast: false matrix: target: [aarch64-apple-darwin, x86_64-apple-darwin] include: - target: aarch64-apple-darwin arch: aarch64 platform: darwin-aarch64 - target: x86_64-apple-darwin arch: x86_64 platform: darwin-x86_64 outputs: version: ${{ needs.create-release.outputs.version }} steps: - uses: actions/checkout@v4 - name: Setup bun uses: oven-sh/setup-bun@v2 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install Rust stable uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - name: Rust cache uses: swatinem/rust-cache@v2 with: workspaces: packages/desktop/src-tauri key: ${{ matrix.target }} - name: Install dependencies run: bun install --frozen-lockfile - name: Install Apple Certificate env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} run: | # Create temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db KEYCHAIN_PASSWORD=$(openssl rand -base64 32) security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" # Import certificate echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12 security import $RUNNER_TEMP/certificate.p12 \ -P "$APPLE_CERTIFICATE_PASSWORD" \ -A -t cert -f pkcs12 \ -k "$KEYCHAIN_PATH" security list-keychain -d user -s "$KEYCHAIN_PATH" security set-key-partition-list -S apple-tool:,apple:,codesign: \ -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - name: Set up notarization credentials env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} run: | # Validate secrets are set if [ -z "$APPLE_ID" ] || [ -z "$APPLE_TEAM_ID" ] || [ -z "$APPLE_PASSWORD" ]; then echo "Error: Missing Apple notarization credentials" exit 1 fi xcrun notarytool store-credentials "openchamber-notarize" \ --apple-id "$APPLE_ID" \ --team-id "$APPLE_TEAM_ID" \ --password "$APPLE_PASSWORD" - name: Build UI package run: bun run --cwd packages/ui build - name: Build Desktop app # Note: We use inline commands instead of desktop:build to pass architecture-specific --target flag # This enables cross-compilation for both arm64 and x86_64 from the same runner run: bun run --cwd packages/desktop build && bun run --cwd packages/desktop tauri build --target ${{ matrix.target }} env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - name: Prepare release artifacts run: | mkdir -p artifacts VERSION="${{ needs.create-release.outputs.version }}" # Copy DMG (Tauri names it with the target triple in the path) DMG_PATH="packages/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg" if ls $DMG_PATH 1> /dev/null 2>&1; then cp $DMG_PATH artifacts/ else echo "Error: DMG file not found at $DMG_PATH" exit 1 fi # Copy tar.gz and signature for updater TAR_PATH="packages/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.tar.gz" SIG_PATH="packages/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.tar.gz.sig" if ls $TAR_PATH 1> /dev/null 2>&1; then cp $TAR_PATH artifacts/ else echo "Error: tar.gz file not found at $TAR_PATH" exit 1 fi if ls $SIG_PATH 1> /dev/null 2>&1; then cp $SIG_PATH artifacts/ else echo "Error: signature file not found at $SIG_PATH" exit 1 fi echo "Successfully prepared artifacts:" ls -lh artifacts/ - name: Generate update manifest run: | VERSION="${{ needs.create-release.outputs.version }}" # Find the signature file SIG_FILE=$(find artifacts -name "*.tar.gz.sig" | head -1) if [ -f "$SIG_FILE" ]; then SIGNATURE=$(cat "$SIG_FILE") else SIGNATURE="" fi # Find the tar.gz file name TAR_FILE=$(find artifacts -name "*.tar.gz" ! -name "*.sig" | head -1) TAR_NAME=$(basename "$TAR_FILE" 2>/dev/null || echo "OpenChamber.app.tar.gz") cat > artifacts/latest-${{ matrix.platform }}.json << EOF { "version": "${VERSION}", "notes": "See release notes at https://github.com/${{ github.repository }}/releases/tag/v${VERSION}", "pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", "platforms": { "${{ matrix.platform }}": { "signature": "${SIGNATURE}", "url": "https://github.com/${{ github.repository }}/releases/download/v${VERSION}/${TAR_NAME}" } } } EOF echo "Generated latest-${{ matrix.platform }}.json:" cat artifacts/latest-${{ matrix.platform }}.json - name: Upload release assets uses: softprops/action-gh-release@v2 with: tag_name: v${{ needs.create-release.outputs.version }} files: | artifacts/*.dmg artifacts/*.tar.gz artifacts/*.tar.gz.sig artifacts/latest-${{ matrix.platform }}.json env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload manifest as artifact uses: actions/upload-artifact@v4 with: name: manifest-${{ matrix.platform }} path: artifacts/latest-${{ matrix.platform }}.json retention-days: 1 publish-npm: needs: create-release runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup bun uses: oven-sh/setup-bun@v2 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: bun install --frozen-lockfile - name: Build packages run: bun run build - name: Create npm tarball working-directory: packages/web run: npm pack - name: Upload npm tarball to release uses: softprops/action-gh-release@v2 with: tag_name: v${{ needs.create-release.outputs.version }} files: packages/web/*.tgz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish to npm if: ${{ github.event.inputs.dry_run != 'true' }} working-directory: packages/web run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} combine-manifests: needs: [create-release, build-desktop-macos] runs-on: ubuntu-latest steps: - name: Download aarch64 manifest uses: actions/download-artifact@v4 with: name: manifest-darwin-aarch64 path: artifacts - name: Download x86_64 manifest uses: actions/download-artifact@v4 with: name: manifest-darwin-x86_64 path: artifacts - name: Combine manifests run: | VERSION="${{ needs.create-release.outputs.version }}" PUB_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" REPO="${{ github.repository }}" # Validate that both manifest files exist and are valid JSON if [ ! -f artifacts/latest-darwin-aarch64.json ]; then echo "Error: aarch64 manifest not found" exit 1 fi if [ ! -f artifacts/latest-darwin-x86_64.json ]; then echo "Error: x86_64 manifest not found" exit 1 fi # Validate JSON structure if ! jq empty artifacts/latest-darwin-aarch64.json 2>/dev/null; then echo "Error: aarch64 manifest is not valid JSON" exit 1 fi if ! jq empty artifacts/latest-darwin-x86_64.json 2>/dev/null; then echo "Error: x86_64 manifest is not valid JSON" exit 1 fi # Validate platform data exists in manifests if ! jq -e '.platforms["darwin-aarch64"]' artifacts/latest-darwin-aarch64.json > /dev/null; then echo "Error: darwin-aarch64 platform data not found in manifest" exit 1 fi if ! jq -e '.platforms["darwin-x86_64"]' artifacts/latest-darwin-x86_64.json > /dev/null; then echo "Error: darwin-x86_64 platform data not found in manifest" exit 1 fi # Use jq to properly combine the manifests jq -n \ --arg version "$VERSION" \ --arg notes "See release notes at https://github.com/${REPO}/releases/tag/v${VERSION}" \ --arg pub_date "$PUB_DATE" \ --slurpfile aarch64 artifacts/latest-darwin-aarch64.json \ --slurpfile x86_64 artifacts/latest-darwin-x86_64.json \ '{ version: $version, notes: $notes, pub_date: $pub_date, platforms: { "darwin-aarch64": $aarch64[0].platforms["darwin-aarch64"], "darwin-x86_64": $x86_64[0].platforms["darwin-x86_64"] } }' > artifacts/latest.json echo "Generated combined latest.json:" cat artifacts/latest.json - name: Upload combined manifest uses: softprops/action-gh-release@v2 with: tag_name: v${{ needs.create-release.outputs.version }} files: artifacts/latest.json env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} finalize-release: needs: [create-release, build-desktop-macos, publish-npm, combine-manifests] runs-on: ubuntu-latest steps: - name: Publish release uses: softprops/action-gh-release@v2 with: tag_name: v${{ needs.create-release.outputs.version }} draft: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}