Skip to content

Commit ea125fc

Browse files
committed
fix(desktop): close release blockers
1 parent 9678ca1 commit ea125fc

22 files changed

Lines changed: 558 additions & 63 deletions

File tree

.github/actionlint.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ self-hosted-runner:
44
- 'ecs-win'
55
- 'ecs-update-sg'
66
- 'ecs-update-64c'
7+
- 'macos-15-intel'
78

89
config-variables: null

.github/workflows/desktop-build.yml

Lines changed: 195 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ jobs:
9494
echo "::error::Published macOS releases require signing and App Store Connect notarization secrets."
9595
exit 1
9696
fi
97-
if [[ "$RUNNER_OS" == "Windows" && -n "$WINDOWS_CERTIFICATE_INPUT" && -z "$WINDOWS_CERTIFICATE_PASSWORD_INPUT" ]]; then
98-
echo "::error::A configured Windows certificate requires its password."
97+
if [[ "$RUNNER_OS" == "Windows" && ( -z "$WINDOWS_CERTIFICATE_INPUT" || -z "$WINDOWS_CERTIFICATE_PASSWORD_INPUT" ) ]]; then
98+
echo "::error::Published Windows releases require an Authenticode certificate and password."
9999
exit 1
100100
fi
101101
@@ -113,23 +113,42 @@ jobs:
113113
OPENWORK_DESKTOP_TARGET: '${{ matrix.target }}'
114114
run: 'npm run build:runtime --prefix packages/desktop-shell --workspaces=false'
115115

116+
- name: 'Run desktop tests'
117+
run: 'npm test --prefix packages/desktop-shell --workspaces=false'
118+
119+
- name: 'Run desktop release tests'
120+
run: 'npm run test:release --prefix packages/desktop-shell --workspaces=false'
121+
116122
- name: 'Configure macOS signing and notarization'
117123
if: "runner.os == 'macOS' && inputs.publish"
118124
shell: 'bash'
119125
env:
120126
APPLE_API_KEY: '${{ secrets.APPLE_API_KEY || secrets.APPLE_NOTARY_KEY_ID }}'
121127
APPLE_API_KEY_P8_INPUT: '${{ secrets.APPLE_API_KEY_P8_BASE64 || secrets.APPLE_NOTARY_API_KEY_P8_BASE64 }}'
122128
APPLE_CERTIFICATE_INPUT: '${{ secrets.APPLE_CERTIFICATE || secrets.MAC_CSC_LINK }}'
129+
APPLE_CERTIFICATE_PASSWORD: '${{ secrets.APPLE_CERTIFICATE_PASSWORD || secrets.MAC_CSC_KEY_PASSWORD }}'
123130
run: |
124131
set -euo pipefail
132+
certificate_path="$RUNNER_TEMP/openwork-signing.p12"
125133
if [[ "$APPLE_CERTIFICATE_INPUT" =~ ^https?:// ]]; then
126-
certificate_path="$RUNNER_TEMP/openwork-signing.p12"
127134
curl --fail --silent --show-error --location "$APPLE_CERTIFICATE_INPUT" --output "$certificate_path"
128-
certificate="$(base64 < "$certificate_path" | tr -d '\n')"
129135
else
130-
certificate="${APPLE_CERTIFICATE_INPUT#*base64,}"
136+
CERTIFICATE_PATH="$certificate_path" node -e "require('node:fs').writeFileSync(process.env.CERTIFICATE_PATH, Buffer.from(process.env.APPLE_CERTIFICATE_INPUT.replace(/^.*base64,/, ''), 'base64'))"
137+
fi
138+
keychain="$RUNNER_TEMP/openwork-signing.keychain-db"
139+
keychain_password="$(openssl rand -hex 32)"
140+
security create-keychain -p "$keychain_password" "$keychain"
141+
security set-keychain-settings -lut 21600 "$keychain"
142+
security unlock-keychain -p "$keychain_password" "$keychain"
143+
security import "$certificate_path" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain"
144+
security list-keychains -d user -s "$keychain" login.keychain-db
145+
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain"
146+
identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)"
147+
if [[ -z "$identity" ]]; then
148+
echo "::error::Developer ID Application identity was not found."
149+
exit 1
131150
fi
132-
echo "APPLE_CERTIFICATE=$certificate" >> "$GITHUB_ENV"
151+
echo "APPLE_SIGNING_IDENTITY=$identity" >> "$GITHUB_ENV"
133152
key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8"
134153
APPLE_KEY_PATH="$key_path" node -e "require('node:fs').writeFileSync(process.env.APPLE_KEY_PATH, Buffer.from(process.env.APPLE_API_KEY_P8_INPUT, 'base64'), { mode: 0o600 })"
135154
echo "APPLE_API_KEY_PATH=$key_path" >> "$GITHUB_ENV"
@@ -141,7 +160,6 @@ jobs:
141160
WINDOWS_CERTIFICATE_INPUT: '${{ secrets.WINDOWS_CERTIFICATE || secrets.WIN_CSC_LINK }}'
142161
WINDOWS_CERTIFICATE_PASSWORD_INPUT: '${{ secrets.WINDOWS_CERTIFICATE_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD }}'
143162
run: |
144-
if (-not $env:WINDOWS_CERTIFICATE_INPUT) { exit 0 }
145163
$certificatePath = Join-Path $env:RUNNER_TEMP 'openwork-signing.pfx'
146164
if ($env:WINDOWS_CERTIFICATE_INPUT -match '^https?://') {
147165
Invoke-WebRequest -Uri $env:WINDOWS_CERTIFICATE_INPUT -OutFile $certificatePath
@@ -154,6 +172,27 @@ jobs:
154172
if (-not $certificate.HasPrivateKey) { throw 'The Windows certificate has no private key.' }
155173
"WINDOWS_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append
156174
175+
- name: 'Sign bundled runtime binaries (macOS)'
176+
if: "runner.os == 'macOS' && inputs.publish"
177+
shell: 'bash'
178+
run: |
179+
set -euo pipefail
180+
while IFS= read -r -d '' binary; do
181+
if ! file "$binary" | grep -q 'Mach-O'; then continue; fi
182+
args=(--force --sign "$APPLE_SIGNING_IDENTITY" --options runtime --timestamp)
183+
if [[ "$binary" == */node/bin/node ]]; then
184+
args+=(--entitlements packages/desktop-shell/src-tauri/NodeEntitlements.plist)
185+
fi
186+
codesign "${args[@]}" "$binary"
187+
done < <(find packages/desktop-shell/runtime/openwork -type f -print0)
188+
189+
- name: 'Refresh bundled runtime checksums (macOS)'
190+
if: "runner.os == 'macOS' && inputs.publish"
191+
run: 'node packages/desktop-shell/scripts/prepare-runtime.js --refresh-checksums'
192+
193+
- name: 'Verify bundled runtime'
194+
run: 'npm run smoke:runtime --prefix packages/desktop-shell --workspaces=false'
195+
157196
- name: 'Configure platform signing'
158197
shell: 'bash'
159198
env:
@@ -165,22 +204,161 @@ jobs:
165204
- name: 'Build desktop artifacts'
166205
uses: 'tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f' # v1.0.0
167206
env:
168-
GITHUB_TOKEN: "${{ inputs.publish && secrets.GITHUB_TOKEN || '' }}"
169207
APPLE_API_ISSUER: "${{ runner.os == 'macOS' && inputs.publish && (secrets.APPLE_API_ISSUER || secrets.APPLE_NOTARY_ISSUER_ID) || '' }}"
170208
APPLE_API_KEY: "${{ runner.os == 'macOS' && inputs.publish && (secrets.APPLE_API_KEY || secrets.APPLE_NOTARY_KEY_ID) || '' }}"
171209
APPLE_CERTIFICATE_PASSWORD: "${{ runner.os == 'macOS' && inputs.publish && (secrets.APPLE_CERTIFICATE_PASSWORD || secrets.MAC_CSC_KEY_PASSWORD) || '' }}"
172-
APPLE_SIGNING_IDENTITY: "${{ runner.os == 'macOS' && inputs.publish && secrets.APPLE_SIGNING_IDENTITY || '' }}"
173210
TAURI_SIGNING_PRIVATE_KEY: "${{ inputs.publish && secrets.TAURI_SIGNING_PRIVATE_KEY || '' }}"
174211
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "${{ inputs.publish && secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || '' }}"
175212
with:
176213
projectPath: 'packages/desktop-shell'
177214
args: '--config src-tauri/release.conf.json --target ${{ matrix.target }}'
178-
tagName: "${{ inputs.publish && inputs.tag || '' }}"
179-
releaseName: "${{ inputs.publish && inputs.release_name || '' }}"
180-
releaseDraft: '${{ inputs.draft }}'
181-
prerelease: '${{ inputs.prerelease }}'
182-
generateReleaseNotes: true
183-
uploadUpdaterJson: '${{ inputs.publish }}'
184-
uploadUpdaterSignatures: '${{ inputs.publish }}'
215+
uploadUpdaterJson: false
216+
uploadUpdaterSignatures: false
185217
updaterJsonPreferNsis: true
186-
uploadWorkflowArtifacts: true
218+
uploadWorkflowArtifacts: false
219+
220+
- name: 'Verify macOS signature'
221+
if: "runner.os == 'macOS' && inputs.publish"
222+
shell: 'bash'
223+
run: |
224+
set -euo pipefail
225+
app="$(find packages/desktop-shell/src-tauri/target/${{ matrix.target }}/release/bundle/macos -maxdepth 1 -name '*.app' -print -quit)"
226+
codesign --verify --deep --strict --verbose=2 "$app"
227+
spctl --assess --type execute --verbose=2 "$app"
228+
229+
- name: 'Verify Windows signature'
230+
if: "runner.os == 'Windows' && inputs.publish"
231+
shell: 'pwsh'
232+
run: |
233+
$installer = Get-ChildItem packages/desktop-shell/src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*.exe | Select-Object -First 1
234+
$signature = Get-AuthenticodeSignature $installer.FullName
235+
if ($signature.Status -ne 'Valid') { throw "Invalid Authenticode signature: $($signature.Status)" }
236+
237+
- name: 'Smoke packaged application (macOS)'
238+
if: "runner.os == 'macOS'"
239+
shell: 'bash'
240+
working-directory: 'packages/desktop-shell'
241+
run: |
242+
set -euo pipefail
243+
executable="$(find src-tauri/target/${{ matrix.target }}/release/bundle/macos -path '*.app/Contents/MacOS/*' -type f -perm -111 -print -quit)"
244+
npm run smoke:packaged -- "$executable"
245+
246+
- name: 'Smoke packaged application (Windows)'
247+
if: "runner.os == 'Windows'"
248+
shell: 'pwsh'
249+
working-directory: 'packages/desktop-shell'
250+
run: |
251+
$executable = Get-ChildItem src-tauri/target/${{ matrix.target }}/release/openwork-desktop.exe | Select-Object -First 1
252+
npm run smoke:packaged -- $executable.FullName
253+
254+
- name: 'Smoke packaged application (Linux)'
255+
if: "runner.os == 'Linux'"
256+
shell: 'bash'
257+
working-directory: 'packages/desktop-shell'
258+
run: 'xvfb-run -a npm run smoke:packaged -- src-tauri/target/${{ matrix.target }}/release/openwork-desktop'
259+
260+
- name: 'Collect verified artifacts'
261+
shell: 'bash'
262+
run: |
263+
set -euo pipefail
264+
destination="$RUNNER_TEMP/openwork-desktop-artifacts"
265+
mkdir -p "$destination"
266+
bundle_root="packages/desktop-shell/src-tauri/target/${{ matrix.target }}/release/bundle"
267+
while IFS= read -r -d '' artifact; do
268+
name="$(basename "$artifact")"
269+
if [[ "$RUNNER_OS" == 'macOS' ]]; then
270+
case "$name" in
271+
*.app.tar.gz.sig) name="${name%.app.tar.gz.sig}-${{ matrix.target }}.app.tar.gz.sig" ;;
272+
*.app.tar.gz) name="${name%.app.tar.gz}-${{ matrix.target }}.app.tar.gz" ;;
273+
*.dmg) name="OpenWork-${{ matrix.target }}.dmg" ;;
274+
*) continue ;;
275+
esac
276+
elif [[ "$RUNNER_OS" == 'Windows' ]]; then
277+
case "$name" in *-setup.exe|*-setup.exe.sig) ;; *) continue ;; esac
278+
else
279+
case "$name" in *.AppImage|*.AppImage.sig|*.deb|*.deb.sig) ;; *) continue ;; esac
280+
fi
281+
cp "$artifact" "$destination/${name// /-}"
282+
done < <(find "$bundle_root" -mindepth 2 -maxdepth 2 -type f \( -name '*.dmg' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.exe' -o -name '*.app.tar.gz' -o -name '*.sig' \) -print0)
283+
if [[ -z "$(find "$destination" -type f -print -quit)" ]]; then
284+
echo '::error::No desktop artifacts were produced.'
285+
exit 1
286+
fi
287+
288+
- name: 'Upload verified artifacts'
289+
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # v4
290+
with:
291+
name: 'openwork-desktop-${{ matrix.target }}'
292+
path: '${{ runner.temp }}/openwork-desktop-artifacts/*'
293+
if-no-files-found: 'error'
294+
retention-days: 14
295+
296+
publish:
297+
name: 'Publish verified release'
298+
if: 'inputs.publish'
299+
needs: 'desktop'
300+
runs-on: 'ubuntu-latest'
301+
permissions:
302+
contents: 'write'
303+
steps:
304+
- name: 'Check out source'
305+
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
306+
with:
307+
persist-credentials: false
308+
309+
- name: 'Download verified artifacts'
310+
uses: 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' # v4
311+
with:
312+
pattern: 'openwork-desktop-*'
313+
path: 'release-assets'
314+
merge-multiple: true
315+
316+
- name: 'Generate updater manifest and checksums'
317+
shell: 'bash'
318+
env:
319+
RELEASE_TAG: '${{ inputs.tag }}'
320+
RELEASE_VERSION: '${{ inputs.version }}'
321+
run: |
322+
set -euo pipefail
323+
node .github/scripts/create-desktop-update-manifest.mjs --assets release-assets --repository "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --version "$RELEASE_VERSION" --output release-assets/latest.json
324+
(cd release-assets && sha256sum -- * > SHA256SUMS.txt)
325+
326+
- name: 'Create GitHub release'
327+
env:
328+
GH_TOKEN: '${{ github.token }}'
329+
RELEASE_DRAFT: '${{ inputs.draft }}'
330+
RELEASE_NAME: '${{ inputs.release_name }}'
331+
RELEASE_PRERELEASE: '${{ inputs.prerelease }}'
332+
RELEASE_TAG: '${{ inputs.tag }}'
333+
run: |
334+
set -euo pipefail
335+
args=("$RELEASE_TAG" release-assets/* --target "$GITHUB_SHA" --title "$RELEASE_NAME" --generate-notes --latest=false)
336+
if [[ "$RELEASE_DRAFT" == 'true' ]]; then args+=(--draft); fi
337+
if [[ "$RELEASE_PRERELEASE" == 'true' ]]; then args+=(--prerelease); fi
338+
gh release create "${args[@]}"
339+
340+
- name: 'Update stable updater feed'
341+
if: 'inputs.draft == false && inputs.prerelease == false'
342+
env:
343+
GH_TOKEN: '${{ github.token }}'
344+
RELEASE_VERSION: '${{ inputs.version }}'
345+
run: |
346+
set -euo pipefail
347+
if gh release view desktop-latest >/dev/null 2>&1; then
348+
directory="$(mktemp -d)"
349+
trap 'rm -rf "$directory"' EXIT
350+
gh release download desktop-latest --dir "$directory" --pattern 'latest.json'
351+
current="$(jq -r '.version' "$directory/latest.json")"
352+
if [[ ! "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
353+
echo "::error::Current Desktop stable feed has an invalid version: $current"
354+
exit 1
355+
fi
356+
newest="$(printf '%s\n%s\n' "$RELEASE_VERSION" "$current" | sort -V | tail -n 1)"
357+
if [[ "$current" != "$RELEASE_VERSION" && "$newest" == "$current" ]]; then
358+
echo "::notice::Desktop $RELEASE_VERSION will not replace newer stable feed $current."
359+
exit 0
360+
fi
361+
gh release upload desktop-latest release-assets/latest.json --clobber
362+
else
363+
gh release create desktop-latest release-assets/latest.json --title 'OpenWork Desktop latest' --notes 'Stable desktop updater feed.' --latest=false
364+
fi

.github/workflows/desktop-release.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ permissions:
3333
contents: 'read'
3434

3535
concurrency:
36-
group: 'desktop-release-${{ inputs.version }}'
36+
group: "desktop-release-${{ inputs.dry_run && inputs.version || 'publish' }}"
3737
cancel-in-progress: false
3838

3939
jobs:
@@ -52,7 +52,9 @@ jobs:
5252
env:
5353
INPUT_VERSION: '${{ inputs.version }}'
5454
INPUT_RELEASE_NAME: '${{ inputs.release_name }}'
55+
IS_DRAFT: '${{ inputs.draft }}'
5556
IS_DRY_RUN: '${{ inputs.dry_run }}'
57+
IS_PRERELEASE: '${{ inputs.prerelease }}'
5658
SOURCE_BRANCH: '${{ github.ref_name }}'
5759
run: |
5860
set -euo pipefail
@@ -65,6 +67,14 @@ jobs:
6567
echo "::error::Published desktop releases must run from main."
6668
exit 1
6769
fi
70+
if [[ "$IS_PRERELEASE" == "true" && ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then
71+
echo "::error::Prereleases require a SemVer prerelease suffix: $INPUT_VERSION"
72+
exit 1
73+
fi
74+
if [[ "$IS_DRY_RUN" == "false" && "$IS_DRAFT" == "false" && "$IS_PRERELEASE" == "false" && ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
75+
echo "::error::Published stable releases require an X.Y.Z version: $INPUT_VERSION"
76+
exit 1
77+
fi
6878
if [[ "$INPUT_RELEASE_NAME" == *$'\n'* || "$INPUT_RELEASE_NAME" == *$'\r'* || ${#INPUT_RELEASE_NAME} -gt 200 ]]; then
6979
echo "::error::Release names must be a single line up to 200 characters."
7080
exit 1

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ pr_body.md
9595
packages/cli/src/generated/
9696
packages/core/src/generated/
9797
packages/web-templates/src/generated/
98+
packages/desktop-shell/src-tauri/permissions/autogenerated/
9899
.integration-tests/
99100
packages/vscode-ide-companion/*.vsix
100101

docs/design/2026-07-31-desktop-web-shell-release.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ flowchart LR
3232
C -->|authenticated loopback URL| D[Existing Web Shell]
3333
A -->|retry / choose workspace / logs| B
3434
B -->|exit event| A
35-
E[GitHub latest.json + installers] -->|signed updater| B
35+
E[GitHub desktop-latest/latest.json + installers] -->|signed updater| B
3636
```
3737

3838
### 组件职责
@@ -128,7 +128,7 @@ Tauri updater 使用签名更新产物和固定公开 key。应用启动后后
128128
- 检查失败:写日志,不阻塞启动。
129129
- 有更新:bootstrap/Web Shell 上方显示原生确认对话框;用户确认后下载并安装,然后重启。
130130

131-
发布 CI 使用 `TAURI_SIGNING_PRIVATE_KEY``TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 生成 updater signatures。`latest.json` 指向同一 GitHub Release 的平台更新包。只有非 draft、非 prerelease 发布会更新固定的 `desktop-latest` feed release。
131+
发布 CI 使用 `TAURI_SIGNING_PRIVATE_KEY``TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 生成 updater signatures。`latest.json` 指向版本化 GitHub Release 的平台更新包。只有非 draft、非 prerelease 发布会更新固定的 `desktop-latest` feed release,客户端只读取该固定 feed
132132

133133
## 平台发布矩阵
134134

packages/channels/base/src/ChannelBase.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,8 @@ export interface ChannelBaseOptions {
212212
proxy?: string;
213213
/** Adapter-owned persistent state directory. */
214214
stateDir?: string;
215+
/** Called when an adapter becomes permanently unavailable after connecting. */
216+
onTerminalDisconnect?: (error: Error) => void;
215217
channelMemory?: ChannelMemoryCallbacks;
216218
memoryIntentClassifier?: ChannelMemoryIntentClassifier;
217219
channelMemoryRecallObserver?: (
@@ -373,6 +375,7 @@ export abstract class ChannelBase {
373375
protected proxy?: string;
374376
/** Adapter-owned persistent state directory, when supplied by the runtime. */
375377
protected readonly stateDir?: string;
378+
protected readonly onTerminalDisconnect?: (error: Error) => void;
376379
private readonly channelMemory?: ChannelMemoryCallbacks;
377380
private readonly memoryIntentClassifier?: ChannelMemoryIntentClassifier;
378381
private readonly channelMemoryRecallObserver?: (
@@ -810,6 +813,7 @@ export abstract class ChannelBase {
810813
this.bridge = bridge;
811814
this.proxy = options?.proxy;
812815
this.stateDir = options?.stateDir;
816+
this.onTerminalDisconnect = options?.onTerminalDisconnect;
813817
this.identity = Object.freeze(this.resolveIdentity(name, config));
814818
this.memoryScope = Object.freeze(this.resolveMemoryScope(name, config));
815819
this.channelMemory = options?.channelMemory;

0 commit comments

Comments
 (0)