Files
openchamber/.github/workflows/release.yml
T
Bohdan Triapitsyn de1b85ac56 feat(voice): first-class voice input and local TTS across web, desktop, and mobile (#2018)
Complete rebuild of voice input on a server-authoritative streaming
architecture, replacing the legacy Web Speech / whole-blob / WASM engines
and the dead voice-agent layer (~4k lines removed).

Speech-to-text (dictation):
- Client streams 16 kHz mono PCM16 chunks over /api/dictation/ws with
  seq/ack ordering; buffered audio is retained and replayed on reconnect
- Server transcribes and streams live partial transcripts back;
  segments auto-commit every ~15s with silence suppression and adaptive
  finalization timeouts
- Local provider (default, zero config): sherpa-onnx models in a forked
  worker process — auto-download with progress, staged extraction with
  verification, corrupt-model auto-recovery, idle shutdown after 5 min
- Model catalog with settings picker (accuracy/speed ratings, sizes,
  download/delete): Parakeet TDT v2 (English) and v3 (25 European
  languages, auto-detected), Whisper base and tiny (multilingual, light)
- OpenAI-compatible provider for any Whisper endpoint
- Composer overlay with live transcript, volume meter, timer, and
  cancel / insert / insert-and-send actions; failed transcriptions keep
  their audio for retry or accepting the partial text as-is
- Configurable keyboard shortcut (default mod+alt+v) toggles dictation;
  Enter confirms and Escape cancels while recording
- Overlay is pixel-aligned with the composer (measured footer height,
  matching paddings/typography/gaps) — no layout shift when toggling

Text-to-speech:
- Local Kokoro provider (English, 11 voices) synthesized in the same
  worker via /api/dictation/tts/speak, managed by the shared model
  pipeline; sentence-pipelined playback keeps time-to-first-audio at
  ~1 sentence regardless of message length, and stop cancels in-flight
  synthesis
- Sanitizer keeps inline-code content (strips backticks only), reads
  interword slashes aloud, and removes only absolute file paths

Settings:
- Voice page unified: a single read-aloud toggle owns all playback
  options (the confusing "Enable Voice Mode" is gone); a new "Enable
  voice input" toggle (default on, persisted to settings.json) hides
  the composer mic entirely when disabled

Mobile and transport:
- iOS/Android microphone permissions added (dictation was previously
  impossible on mobile)
- Fixed Android WebSocket upgrades: the Capacitor WebView origin
  (https://localhost) was missing from the packaged-client allowlist,
  403-ing every WS connection — root cause of the old mobile SSE lock,
  which is now removed for all transports

Security and conventions:
- All HTTP routes sit behind the global /api auth gate; the WS upgrade
  explicitly validates the UI session and origin, with oc_url_token
  narrowly allowlisted and covered by tests; the dictation socket mints
  a fresh URL token before connecting
- Routes register before the generic OpenCode proxy; the client goes
  through runtimeFetch/getRuntimeUrlResolver, and runtime switches
  reset the dictation socket
- VS Code deliberately reports dictation as unavailable (no server
  process in that runtime)

CI: workflow Node bumped 20 -> 22 to match the repo engines and fix
better-sqlite3 installs broken by node-gyp@latest on Node 20.

New dependency: sherpa-onnx-node (prebuilt N-API; macOS/Linux x64+arm64,
Windows x64 — Windows-on-ARM falls back to the OpenAI-compatible provider)
2026-07-04 02:48:07 +03:00

499 lines
18 KiB
YAML

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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
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 }}
publish-npm:
needs: create-release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
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
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
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 }}
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Get bundled OpenCode CLI version
id: opencode_cli_version
shell: bash
run: |
VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Cache bundled OpenCode CLI artifact
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: packages/electron/.cache/opencode-cli
key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }}
restore-keys: |
opencode-cli-${{ runner.os }}-${{ matrix.arch }}-
- 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 prepare:opencode-cli
bun run verify:opencode-cli
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
bunx electron-builder --mac --${{ matrix.arch }} --publish=never
bun run verify:opencode-cli:packaged
- 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>$key</key>"; then
echo "Error: required entitlement missing: $key"
exit 1
fi
done
- name: Upload DMG / ZIP / blockmaps to release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: latest-yml-${{ matrix.target }}
path: packages/electron/dist/latest-mac.yml
retention-days: 1
build-desktop-electron-windows:
needs: create-release
# windows-latest currently resolves to a runner with Visual Studio 18,
# which this electron/node-gyp stack does not detect correctly.
runs-on: windows-2022
strategy:
fail-fast: false
matrix:
include:
- arch: x64
target: x86_64-pc-windows-msvc
platform: win32-x64
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Get bundled OpenCode CLI version
id: opencode_cli_version
shell: bash
run: |
VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Cache bundled OpenCode CLI artifact
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: packages/electron/.cache/opencode-cli
key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }}
restore-keys: |
opencode-cli-${{ runner.os }}-${{ matrix.arch }}-
- name: Build web assets
working-directory: packages/electron
run: bun run build:web-assets
- name: Prepare bundled OpenCode CLI
working-directory: packages/electron
shell: bash
run: |
bun run prepare:opencode-cli
bun run verify:opencode-cli
- name: Bundle main process
working-directory: packages/electron
run: bun run bundle:main
- name: Rebuild native modules
working-directory: packages/electron
shell: bash
# 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.
run: node ./scripts/rebuild-native.mjs
- name: Build Windows app
working-directory: packages/electron
shell: bash
run: |
node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never
bun run verify:opencode-cli:packaged
- name: Upload installer to release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
tag_name: v${{ needs.create-release.outputs.version }}
files: |
packages/electron/dist/*.exe
packages/electron/dist/*.blockmap
packages/electron/dist/latest.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload update manifest as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: latest-yml-${{ matrix.target }}
path: packages/electron/dist/latest.yml
retention-days: 1
combine-electron-manifests:
needs: [create-release, build-desktop-electron-macos]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- name: Download per-arch latest-mac.yml
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
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 manifests to release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
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-electron-macos, build-desktop-electron-windows, publish-npm, 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@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
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 @- <<JSON
{
"event_type": "site_refresh_requested",
"client_payload": {
"source_repo": "${{ github.repository }}",
"release_tag": "v$VERSION"
}
}
JSON