Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
217 changes: 217 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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"
40 changes: 37 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 的轻量版本:

Expand All @@ -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)。

## 代码结构

Expand Down
4 changes: 2 additions & 2 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.7.0</string>
<string>0.8.0</string>
<key>CFBundleVersion</key>
<string>7</string>
<string>8</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
Expand Down
10 changes: 1 addition & 9 deletions Scripts/build_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading