diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..27531f2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,87 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ quality:
+ name: Swift format and lint
+ runs-on: macos-15
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Show toolchain
+ run: swift --version
+ - name: Lint changed Swift formatting
+ env:
+ BASE_REF: ${{ github.event.pull_request.base.sha }}
+ run: Scripts/lint_changed_swift.sh "${BASE_REF:-HEAD^}"
+ - name: Validate package manifest
+ run: swift package describe >/dev/null
+ - name: Lint release scripts
+ run: bash -n Scripts/*.sh
+ - name: Smoke-test release metadata and Homebrew cask
+ run: |
+ temporary_directory="$(mktemp -d)"
+ trap 'rm -rf "$temporary_directory"' EXIT
+ mkdir -p "$temporary_directory/release"
+ printf 'arm64 archive' >"$temporary_directory/release/Gloss-macos-arm64.zip"
+ printf 'x86_64 archive' >"$temporary_directory/release/Gloss-macos-x86_64.zip"
+ Scripts/generate_release_metadata.sh \
+ "$temporary_directory/release/Gloss-macos-arm64.zip" \
+ "$temporary_directory/release/Gloss-macos-x86_64.zip" \
+ 0.0.0 \
+ "$temporary_directory/release" \
+ v0.0.0 \
+ SunChJ/gloss-releases
+ grep -F \
+ 'https://github.com/SunChJ/gloss-releases/releases/download/v0.0.0/' \
+ "$temporary_directory/release/gloss-release-manifest.json"
+ grep -F \
+ 'https://github.com/SunChJ/gloss-releases/releases/download/v0.0.0/' \
+ "$temporary_directory/release/Casks/gloss.rb"
+ (
+ cd "$temporary_directory/release"
+ shasum -a 256 --check SHA256SUMS
+ )
+
+ test:
+ name: Swift tests
+ runs-on: macos-15
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ - name: Run tests
+ run: swift test --parallel
+
+ release-build:
+ name: Release build (${{ matrix.architecture }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - architecture: arm64
+ runner: macos-15
+ - architecture: x86_64
+ runner: macos-15-intel
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ - name: Verify runner architecture
+ run: test "$(uname -m)" = "${{ matrix.architecture }}"
+ - name: Build release products
+ run: swift build --configuration release
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..38f8313
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,217 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*"
+ workflow_dispatch:
+ inputs:
+ release_tag:
+ description: Existing tag to package without publishing
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+env:
+ GLOSS_RELEASE_REPOSITORY: SunChJ/gloss-releases
+ GLOSS_HOMEBREW_TAP_REPOSITORY: SunChJ/homebrew-tap
+ GLOSS_HOMEBREW_WORKFLOW: update-cask.yml
+
+concurrency:
+ group: release-${{ github.ref_name }}-${{ inputs.release_tag }}
+ cancel-in-progress: false
+
+jobs:
+ preflight:
+ name: Validate publication credentials
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Require release credentials
+ env:
+ DISTRIBUTION_TOKEN: ${{ secrets.GLOSS_DISTRIBUTION_TOKEN }}
+ EXTENSION_TOKEN: ${{ secrets.GLOSS_EXTENSION_TOKEN }}
+ run: |
+ missing=()
+ [[ -n "$EXTENSION_TOKEN" ]] || missing+=("GLOSS_EXTENSION_TOKEN")
+ if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
+ [[ -n "$DISTRIBUTION_TOKEN" ]] || missing+=("GLOSS_DISTRIBUTION_TOKEN")
+ fi
+ if [[ ${#missing[@]} -ne 0 ]]; then
+ printf 'Release workflow requires secret: %s\n' "${missing[@]}" >&2
+ exit 1
+ fi
+
+ build:
+ name: Build macOS ${{ matrix.architecture }}
+ needs:
+ - preflight
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - architecture: arm64
+ runner: macos-15
+ - architecture: x86_64
+ runner: macos-15-intel
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 90
+ env:
+ RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }}
+ GLOSS_SIGN_IDENTITY: "-"
+ steps:
+ - name: Check out Gloss
+ uses: actions/checkout@v4
+ with:
+ path: gloss
+ ref: ${{ github.event_name == 'push' && github.ref || inputs.release_tag }}
+ - name: Check out browser extensions
+ uses: actions/checkout@v4
+ with:
+ repository: SunChJ/personal-immersive-translator
+ ref: 3e9c7c8cb75ce4b08e56a714ee0e4eb7ebaa652e
+ path: personal-immersive-translator
+ token: ${{ secrets.GLOSS_EXTENSION_TOKEN }}
+ persist-credentials: false
+ - name: Validate release version and runner architecture
+ working-directory: gloss
+ env:
+ EXPECTED_ARCHITECTURE: ${{ matrix.architecture }}
+ run: |
+ version="${RELEASE_TAG#v}"
+ plist_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Resources/Info.plist)"
+ if [[ "$version" != "$plist_version" ]]; then
+ echo "Tag version $version does not match Info.plist version $plist_version." >&2
+ exit 1
+ fi
+ if [[ "$(uname -m)" != "$EXPECTED_ARCHITECTURE" ]]; then
+ echo "Runner architecture $(uname -m) does not match $EXPECTED_ARCHITECTURE." >&2
+ exit 1
+ fi
+ - name: Build Gloss.app
+ working-directory: gloss
+ run: Scripts/build_app.sh
+ - name: Verify app architecture and ad-hoc signature
+ working-directory: gloss
+ env:
+ EXPECTED_ARCHITECTURE: ${{ matrix.architecture }}
+ run: |
+ lipo -verify_arch "$EXPECTED_ARCHITECTURE" dist/Gloss.app/Contents/MacOS/Gloss
+ codesign --verify --deep --strict --verbose=2 dist/Gloss.app
+ codesign --display --verbose=4 dist/Gloss.app 2>&1 \
+ | grep -F "Signature=adhoc"
+ - name: Package architecture-specific app
+ working-directory: gloss
+ env:
+ GLOSS_RELEASE_ARCHITECTURE: ${{ matrix.architecture }}
+ run: Scripts/package_release.sh
+ - name: Upload architecture artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: Gloss-${{ matrix.architecture }}
+ path: gloss/dist/release/Gloss-macos-${{ matrix.architecture }}.zip
+ if-no-files-found: error
+
+ assemble:
+ name: Assemble release metadata
+ needs:
+ - build
+ runs-on: macos-15
+ timeout-minutes: 15
+ env:
+ RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }}
+ steps:
+ - name: Check out Gloss
+ uses: actions/checkout@v4
+ with:
+ path: gloss
+ ref: ${{ github.event_name == 'push' && github.ref || inputs.release_tag }}
+ - name: Download architecture artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: Gloss-*
+ path: release-input
+ merge-multiple: true
+ - name: Generate checksums, manifest, and Homebrew cask
+ working-directory: gloss
+ run: |
+ version="${RELEASE_TAG#v}"
+ mkdir -p dist/release
+ cp ../release-input/Gloss-macos-arm64.zip dist/release/
+ cp ../release-input/Gloss-macos-x86_64.zip dist/release/
+ Scripts/generate_release_metadata.sh \
+ dist/release/Gloss-macos-arm64.zip \
+ dist/release/Gloss-macos-x86_64.zip \
+ "$version" \
+ dist/release \
+ "$RELEASE_TAG" \
+ "$GLOSS_RELEASE_REPOSITORY"
+ - name: Upload combined workflow artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: Gloss-release-${{ env.RELEASE_TAG }}
+ path: |
+ gloss/dist/release/Gloss-macos-arm64.zip
+ gloss/dist/release/Gloss-macos-x86_64.zip
+ gloss/dist/release/SHA256SUMS
+ gloss/dist/release/gloss-release-manifest.json
+ gloss/dist/release/Casks/gloss.rb
+ if-no-files-found: error
+ - name: Publish GitHub release assets
+ if: github.event_name == 'push'
+ working-directory: gloss
+ env:
+ GH_TOKEN: ${{ secrets.GLOSS_DISTRIBUTION_TOKEN }}
+ run: |
+ version="${RELEASE_TAG#v}"
+ if release_is_draft="$(
+ gh release view "$RELEASE_TAG" \
+ --repo "$GLOSS_RELEASE_REPOSITORY" \
+ --json isDraft \
+ --jq '.isDraft' 2>/dev/null
+ )"; then
+ if [[ "$release_is_draft" != "true" ]]; then
+ echo "Release $RELEASE_TAG is already published and must remain immutable." >&2
+ exit 1
+ fi
+ else
+ notes_file="docs/release-notes/v$version.md"
+ if [[ -f "$notes_file" ]]; then
+ gh release create "$RELEASE_TAG" \
+ --repo "$GLOSS_RELEASE_REPOSITORY" \
+ --target main \
+ --draft \
+ --title "Gloss $version" \
+ --notes-file "$notes_file"
+ else
+ gh release create "$RELEASE_TAG" \
+ --repo "$GLOSS_RELEASE_REPOSITORY" \
+ --target main \
+ --draft \
+ --title "Gloss $version" \
+ --notes "Checksum-pinned, ad-hoc signed macOS release of Gloss $version."
+ fi
+ fi
+ gh release upload "$RELEASE_TAG" \
+ --repo "$GLOSS_RELEASE_REPOSITORY" \
+ dist/release/Gloss-macos-arm64.zip \
+ dist/release/Gloss-macos-x86_64.zip \
+ dist/release/SHA256SUMS \
+ dist/release/gloss-release-manifest.json \
+ dist/release/Casks/gloss.rb \
+ --clobber
+ gh release edit "$RELEASE_TAG" \
+ --repo "$GLOSS_RELEASE_REPOSITORY" \
+ --draft=false
+ - name: Start Homebrew cask update
+ if: github.event_name == 'push'
+ env:
+ GH_TOKEN: ${{ secrets.GLOSS_DISTRIBUTION_TOKEN }}
+ run: |
+ gh workflow run "$GLOSS_HOMEBREW_WORKFLOW" \
+ --repo "$GLOSS_HOMEBREW_TAP_REPOSITORY" \
+ --ref main \
+ -f release_tag="$RELEASE_TAG" \
+ -f release_repository="$GLOSS_RELEASE_REPOSITORY"
diff --git a/README.md b/README.md
index 72b4136..545ca8f 100644
--- a/README.md
+++ b/README.md
@@ -138,7 +138,7 @@ swift run gloss-cli --provider llama 'Hello from local Gloss.'
open dist/Gloss.app
```
-构建脚本会按 `CodexRuntime.lock` 下载并校验固定版本的官方 Rust app-server,把它与许可证一起嵌入 App;本地 provider 当前复用系统安装的 `llama-server`。随后脚本在相邻的 `personal-immersive-translator` 仓库中生成 Chrome/Safari 产物,并把 Chrome 资源与 Safari `.appex` 嵌入 App。结果位于 `dist/Gloss.app`。脚本会优先使用钥匙串中的第一个 Apple Development 身份;没有可用证书时退回临时签名,此时 Safari 配对不可用。正式分发前需要换成 Developer ID 签名和公证。
+构建脚本会按 `CodexRuntime.lock` 下载并校验固定版本的官方 Rust app-server,把它与许可证一起嵌入 App;本地 provider 当前复用系统安装的 `llama-server`。随后脚本在相邻的 `personal-immersive-translator` 仓库中生成 Chrome/Safari 产物,并把 Chrome 资源与 Safari `.appex` 嵌入 App。结果位于 `dist/Gloss.app`。脚本默认使用 `-` 做 ad-hoc codesign;这种签名没有 Apple 开发者身份,Safari 配对不可用。
如果不希望下载或嵌入固定 Rust app-server,可构建依赖用户 Codex CLI 的轻量版本:
@@ -148,13 +148,47 @@ open dist/Gloss.app
该脚本会先确认当前环境中的 `codex app-server` 可用,但不会把 Codex runtime、许可证或版本锁文件放入 App。运行时 Gloss 会查找 `GLOSS_CODEX_BIN`、`PATH`、Homebrew 与常用本地安装路径,并执行 `codex app-server --listen stdio://`。进程与 thread 仍统一经过 `CodexAppServerClient`,因此会复用相同的静态模型目录、隔离工作目录和 MCP/skills/tools 禁用配置,不会退回较慢的默认启动方式。
-需要稳定的本机开发签名时,可显式传入钥匙串中的证书:
+本机调试 Safari 配对时,可显式传入钥匙串中的 Apple Development 证书:
```bash
GLOSS_SIGN_IDENTITY="Apple Development: Your Name (TEAMID)" ./Scripts/build_app.sh
```
-正式分发时使用 `Developer ID Application` 证书执行同一命令;脚本会自动启用 Hardened Runtime 与可信时间戳。随后仍需用 Apple `notarytool` 公证并对 App 执行 `stapler staple`。
+当前公开 Homebrew 发行也明确使用 ad-hoc 签名,不要求 Developer ID 或 Apple 公证。
+
+### BabelDOC runtime 更新
+
+Gloss 可以管理来自 `SunChJ/BabelDOC` GitHub Releases 的固定版本 runtime。更新 manifest 使用
+内置 Ed25519 public key 验证 detached signature,runtime archive 再做 SHA-256 校验;签名或
+校验失败不会替换当前版本。安装器当前开放 stable 通道,并支持版本 pin 和一键 rollback;
+beta、nightly 会在对应的已签名 release alias 上线后再开放。安装过程通过 staging directory 与
+atomic state file 防止半安装状态。完整 manifest schema、安全边界和发布 secret 见
+[Gloss 与 BabelDOC 发行链路](docs/runtime-distribution.md)。
+
+### GitHub Release 与 Homebrew
+
+推送与 `Resources/Info.plist` 一致的 `v*` tag 会运行 Release workflow,产出
+arm64 与 x86_64 两套 `Gloss.app` zip、`SHA256SUMS`、release manifest 和带
+`on_arm` / `on_intel` 校验的 Homebrew cask。私有 `SunChJ/gloss` 只负责构建;ad-hoc
+签名后的资产发布到公开 `SunChJ/gloss-releases`,随后自动 dispatch
+`SunChJ/homebrew-tap` 更新 Cask。下载 URL 不会指向私有主仓。
+
+首次安装以及后续升级为:
+
+```bash
+brew tap sunchj/tap
+brew install --cask sunchj/tap/gloss
+brew update
+brew upgrade --cask sunchj/tap/gloss
+```
+
+Release workflow 使用只读 `GLOSS_EXTENSION_TOKEN` 检出私有浏览器扩展;正式 tag 另外要求
+跨仓库 `GLOSS_DISTRIBUTION_TOKEN`。缺失时 workflow 会在构建和上传前 fail closed。手工
+workflow 不发布,但仍需要 extension token 才能生成完整 App artifact。
+Cask 的 `postflight` 会重新 ad-hoc 签名、移除 quarantine 并验证签名,让安装后启动不弹
+Gatekeeper 交互;这也意味着 macOS 无法验证 Apple 开发者身份或公证票据。公开仓库初始化、
+fine-grained token 权限、完整安全取舍、发行顺序与恢复步骤见
+[发行文档](docs/runtime-distribution.md)。
## 代码结构
diff --git a/Resources/Info.plist b/Resources/Info.plist
index d16d818..e01e96d 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -19,9 +19,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.7.0
+ 0.8.0
CFBundleVersion
- 7
+ 8
CFBundleDocumentTypes
diff --git a/Scripts/build_app.sh b/Scripts/build_app.sh
index 45c227b..647292d 100755
--- a/Scripts/build_app.sh
+++ b/Scripts/build_app.sh
@@ -40,15 +40,7 @@ case "$CODEX_RUNTIME_MODE" in
exit 1
;;
esac
-SIGN_IDENTITY="${GLOSS_SIGN_IDENTITY:-}"
-if [[ -z "$SIGN_IDENTITY" ]] && command -v security >/dev/null 2>&1; then
- SIGN_IDENTITY="$(
- security find-identity -v -p codesigning \
- | sed -nE 's/.*"(Apple Development: [^"]+)".*/\1/p' \
- | sed -n '1p'
- )"
-fi
-SIGN_IDENTITY="${SIGN_IDENTITY:--}"
+SIGN_IDENTITY="${GLOSS_SIGN_IDENTITY:--}"
if [[ ! -x "$PLUGIN_DIR/node_modules/.bin/wxt" ]]; then
npm --prefix "$PLUGIN_DIR" ci
diff --git a/Scripts/generate_homebrew_cask.sh b/Scripts/generate_homebrew_cask.sh
new file mode 100755
index 0000000..3cb94e8
--- /dev/null
+++ b/Scripts/generate_homebrew_cask.sh
@@ -0,0 +1,152 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ $# -lt 4 || $# -gt 8 ]]; then
+ echo "Usage: $0