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: | export TAURI_ENV_TARGET_TRIPLE=${{ matrix.target }} bun run --cwd packages/desktop build bun run --cwd packages/desktop tauri build --target ${{ matrix.target }} env: TAURI_ENV_TARGET_TRIPLE: ${{ matrix.target }} 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: Verify binary architectures run: | set -euo pipefail BUNDLE_DIR="packages/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/macos" if [ ! -d "$BUNDLE_DIR" ]; then echo "❌ Error: bundle directory not found: $BUNDLE_DIR" exit 1 fi APP_PATH=$(find "$BUNDLE_DIR" -maxdepth 2 -name "*.app" -print -quit) if [ -z "$APP_PATH" ]; then echo "❌ Error: .app bundle not found under $BUNDLE_DIR" exit 1 fi echo "🔍 Verifying binary architectures in $APP_PATH" # Extract raw architecture names (macOS file command reports ARM as "arm64") MAIN_ARCH_RAW=$(file "$APP_PATH/Contents/MacOS/openchamber-desktop" | grep -oE 'arm64|x86_64|aarch64' | head -1) SIDEARCH_ARCH_RAW=$(file "$APP_PATH/Contents/MacOS/openchamber-server" | grep -oE 'arm64|x86_64|aarch64' | head -1) # Normalize architecture names (arm64 -> aarch64 for consistency with Rust/Tauri) normalize_arch() { case "$1" in arm64) echo "aarch64" ;; aarch64|x86_64) echo "$1" ;; *) echo "unknown" ;; esac } MAIN_ARCH=$(normalize_arch "$MAIN_ARCH_RAW") SIDEARCH_ARCH=$(normalize_arch "$SIDEARCH_ARCH_RAW") EXPECTED_ARCH=$(echo "${{ matrix.target }}" | grep -oE 'aarch64|x86_64' | head -1) echo " Main: $MAIN_ARCH_RAW → $MAIN_ARCH" echo " Sidecar: $SIDEARCH_ARCH_RAW → $SIDEARCH_ARCH" echo " Expected: $EXPECTED_ARCH" if [ "$MAIN_ARCH" != "$EXPECTED_ARCH" ]; then echo "❌ ERROR: Main binary architecture mismatch!" echo " Expected: $EXPECTED_ARCH" echo " Got: $MAIN_ARCH (raw: $MAIN_ARCH_RAW)" exit 1 fi if [ "$SIDEARCH_ARCH" != "$EXPECTED_ARCH" ]; then echo "❌ ERROR: Sidecar binary architecture mismatch!" echo " Expected: $EXPECTED_ARCH" echo " Got: $SIDEARCH_ARCH (raw: $SIDEARCH_ARCH_RAW)" exit 1 fi echo "✅ Architecture verification passed: both binaries match $EXPECTED_ARCH" - name: Verify macOS entitlements run: | set -euo pipefail BUNDLE_DIR="packages/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/macos" if [ ! -d "$BUNDLE_DIR" ]; then echo "Error: bundle directory not found: $BUNDLE_DIR" exit 1 fi APP_PATH=$(find "$BUNDLE_DIR" -maxdepth 2 -name "*.app" -print -quit) if [ -z "$APP_PATH" ]; then echo "Error: .app bundle not found under $BUNDLE_DIR" echo "Contents:"; ls -la "$BUNDLE_DIR" exit 1 fi echo "Verifying app bundle: $APP_PATH" codesign -vv "$APP_PATH" ENTITLEMENTS=$(codesign -d --entitlements :- "$APP_PATH" 2>&1 || true) echo "$ENTITLEMENTS" if echo "$ENTITLEMENTS" | grep -q "com.apple.security.app-sandbox"; then echo "Error: app sandbox entitlement is present" exit 1 fi for key in \ com.apple.security.cs.allow-jit \ com.apple.security.cs.allow-unsigned-executable-memory \ com.apple.security.cs.disable-executable-page-protection \ com.apple.security.cs.disable-library-validation do if ! echo "$ENTITLEMENTS" | grep -q "$key"; then echo "Error: required entitlement missing: $key" exit 1 fi done - 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 DMG_FILE=$(ls $DMG_PATH | head -n 1) DMG_NAME="OpenChamber_${VERSION}_${{ matrix.platform }}.dmg" cp "$DMG_FILE" "artifacts/$DMG_NAME" 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 TAR_FILE=$(ls $TAR_PATH | head -n 1) TAR_BASE=$(basename "$TAR_FILE") TAR_NAME="${TAR_BASE%.tar.gz}-${{ matrix.platform }}.tar.gz" cp "$TAR_FILE" "artifacts/$TAR_NAME" else echo "Error: tar.gz file not found at $TAR_PATH" exit 1 fi if ls $SIG_PATH 1> /dev/null 2>&1; then SIG_FILE=$(ls $SIG_PATH | head -n 1) SIG_BASE=$(basename "$SIG_FILE") SIG_NAME="${SIG_BASE%.tar.gz.sig}-${{ matrix.platform }}.tar.gz.sig" cp "$SIG_FILE" "artifacts/$SIG_NAME" 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 for this platform SIG_FILE=$(find artifacts -name "*-${{ matrix.platform }}.tar.gz.sig" | head -1) if [ -f "$SIG_FILE" ]; then SIGNATURE=$(cat "$SIG_FILE") else SIGNATURE="" fi # Find the tar.gz file name for this platform TAR_FILE=$(find artifacts -name "*-${{ matrix.platform }}.tar.gz" ! -name "*.sig" | head -1) TAR_NAME=$(basename "$TAR_FILE" 2>/dev/null || echo "OpenChamber-${{ matrix.platform }}.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 }} build-desktop-electron-macos: needs: create-release runs-on: macos-26 strategy: fail-fast: false matrix: include: - target: aarch64-apple-darwin arch: arm64 platform: darwin-aarch64 - target: x86_64-apple-darwin arch: x64 platform: darwin-x86_64 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 dependencies run: bun install --frozen-lockfile - name: Install Apple Certificate env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} run: | KEYCHAIN_PATH=$RUNNER_TEMP/electron-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" 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: Build Electron app working-directory: packages/electron env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} # rebuild-native.mjs reads this to target the right arch when # cross-building (runner is arm64; x64 matrix needs the hint). ELECTRON_BUILDER_ARCH: ${{ matrix.arch }} run: | bun run build:web-assets bun run bundle:main # npmRebuild=false in package.json, so electron-builder won't # recompile native deps on its own — we must rebuild against the # target Electron ABI before packaging, otherwise better-sqlite3/ # node-pty/bun-pty crash on require inside the packaged app. bun run rebuild:native ./node_modules/.bin/electron-builder --mac --${{ matrix.arch }} --publish=never - name: Verify signature + entitlements + notarization run: | set -euo pipefail APP_DIR="packages/electron/dist/mac" [ -d "packages/electron/dist/mac-arm64" ] && APP_DIR="packages/electron/dist/mac-arm64" APP_PATH=$(find "$APP_DIR" -maxdepth 2 -name "*.app" -print -quit) if [ -z "$APP_PATH" ]; then echo "Error: .app not found under packages/electron/dist/mac*" ls -la packages/electron/dist/ exit 1 fi echo "Verifying $APP_PATH" codesign -vv --deep --strict "$APP_PATH" # Require hardened runtime CS_INFO=$(codesign -dv --verbose=4 "$APP_PATH" 2>&1) echo "$CS_INFO" if ! echo "$CS_INFO" | grep -q "flags=.*runtime"; then echo "Error: hardened runtime flag missing" exit 1 fi # Require notary ticket stapled xcrun stapler validate "$APP_PATH" ENTITLEMENTS=$(codesign -d --entitlements :- "$APP_PATH" 2>&1 || true) if echo "$ENTITLEMENTS" | grep -q "com.apple.security.app-sandbox"; then echo "Error: app sandbox entitlement is present" exit 1 fi for key in \ com.apple.security.cs.allow-jit \ com.apple.security.cs.allow-unsigned-executable-memory \ com.apple.security.cs.disable-library-validation do if ! echo "$ENTITLEMENTS" | grep -q "$key"; then echo "Error: required entitlement missing: $key" exit 1 fi done - name: Upload DMG / ZIP / blockmaps to release uses: softprops/action-gh-release@v2 with: tag_name: v${{ needs.create-release.outputs.version }} files: | packages/electron/dist/*.dmg packages/electron/dist/*.zip packages/electron/dist/*.blockmap env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload per-arch latest-mac.yml for merge uses: actions/upload-artifact@v4 with: name: latest-yml-${{ matrix.target }} path: packages/electron/dist/latest-mac.yml retention-days: 1 combine-electron-manifests: needs: [create-release, build-desktop-electron-macos] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Download per-arch latest-mac.yml uses: actions/download-artifact@v4 with: pattern: latest-yml-*-apple-darwin path: artifacts - name: Finalize combined latest-mac.yml env: LATEST_YML_DIR: ${{ github.workspace }}/artifacts GH_REPO: ${{ github.repository }} OPENCHAMBER_VERSION: ${{ needs.create-release.outputs.version }} run: node packages/electron/scripts/finalize-latest-yml.mjs - name: Upload combined latest-mac.yml to release uses: softprops/action-gh-release@v2 with: tag_name: v${{ needs.create-release.outputs.version }} files: ${{ runner.temp }}/latest-mac.yml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} finalize-release: needs: [create-release, build-desktop-macos, build-desktop-electron-macos, publish-npm, combine-manifests, combine-electron-manifests] runs-on: ubuntu-latest env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} DISCORD_UPDATE_ROLE_ID: ${{ secrets.DISCORD_UPDATE_ROLE_ID }} 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 }} - name: Send release to Discord if: ${{ env.DISCORD_WEBHOOK_URL != '' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.create-release.outputs.version }} REPOSITORY: ${{ github.repository }} UPDATE_ROLE_ID: ${{ env.DISCORD_UPDATE_ROLE_ID }} run: | node - <<'NODE' (async () => { const tag = `v${process.env.VERSION}`; const repo = process.env.REPOSITORY; const rawRoleId = (process.env.UPDATE_ROLE_ID || '').trim(); const updateRoleId = /^\d+$/.test(rawRoleId) ? rawRoleId : ''; const releaseRes = await fetch(`https://api.github.com/repos/${repo}/releases/tags/${tag}`, { headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, Accept: 'application/vnd.github+json', }, }); if (!releaseRes.ok) { const body = await releaseRes.text(); throw new Error(`Failed to fetch release ${tag}: ${releaseRes.status} ${body}`); } const release = await releaseRes.json(); const description = (release.body || `OpenChamber ${tag} released.`).slice(0, 4096); const mention = updateRoleId ? `<@&${updateRoleId}>` : ''; const payload = { username: 'OpenChamber Releases', ...(mention ? { content: mention } : {}), ...(updateRoleId ? { allowed_mentions: { roles: [updateRoleId], }, } : {}), embeds: [ { title: release.name || `OpenChamber ${tag}`, url: release.html_url, description, color: 2105893, footer: { text: 'OpenChamber Changelog' }, }, ], }; const discordRes = await fetch(process.env.DISCORD_WEBHOOK_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), }); if (!discordRes.ok) { const body = await discordRes.text(); throw new Error(`Failed to send Discord release: ${discordRes.status} ${body}`); } })().catch((error) => { console.error(error); process.exit(1); }); NODE - name: Trigger openchamber-website site refresh (optional) env: WEBSITE_REPO: openchamber/openchamber-website WEBSITE_TOKEN: ${{ secrets.OPENCHAMBER_WEBSITE_REPO_TOKEN }} VERSION: ${{ needs.create-release.outputs.version }} run: | if [ -z "$WEBSITE_TOKEN" ]; then echo "OPENCHAMBER_WEBSITE_REPO_TOKEN not set; skip site refresh dispatch." exit 0 fi curl --fail-with-body -sS -X POST \ -H "Authorization: Bearer $WEBSITE_TOKEN" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/repos/$WEBSITE_REPO/dispatches \ -d @- <