diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fcdce5a..2dc0e47 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,19 +4,13 @@ on:
push:
tags:
- 'v*'
- workflow_dispatch:
- inputs:
- release_tag:
- description: Existing vX.Y.Z tag; dispatch with the same tag ref via CLI or API
- required: true
- type: string
permissions:
contents: read
concurrency:
- group: release-${{ github.repository }}-${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }}
- cancel-in-progress: false
+ group: release-${{ github.repository }}
+ queue: max
jobs:
publish:
@@ -43,10 +37,10 @@ jobs:
- name: Validate release tag
id: release_tag
env:
- RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }}
+ RELEASE_TAG: ${{ github.ref_name }}
run: |
- if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
- echo "::error title=Invalid release tag::Production releases must use vX.Y.Z, for example v0.10.0. Got ${RELEASE_TAG}."
+ if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
+ echo "::error title=Invalid release tag::Production releases must use vX.Y.Z without leading zeros. Got ${RELEASE_TAG}."
exit 1
fi
@@ -55,66 +49,28 @@ jobs:
exit 1
fi
- if ! TAG_COMMIT=$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}" 2>/dev/null); then
- echo "::error title=Invalid release tag::Tag ${RELEASE_TAG} does not resolve to a commit."
- exit 1
- fi
-
- if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
- if [[ "$GITHUB_REF_TYPE" != "tag" || "$GITHUB_REF_NAME" != "$RELEASE_TAG" ]]; then
- echo "::error title=Invalid manual release ref::Manual releases must be dispatched from tag ${RELEASE_TAG}. Run: gh workflow run release.yml --ref ${RELEASE_TAG} -f release_tag=${RELEASE_TAG}"
- exit 1
- fi
- elif [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
- if [[ "$GITHUB_REF_TYPE" != "tag" || "$GITHUB_REF_NAME" != "$RELEASE_TAG" ]]; then
- echo "::error title=Invalid tag push ref::Expected tag ref ${RELEASE_TAG}, got ${GITHUB_REF_TYPE} ${GITHUB_REF_NAME}."
- exit 1
- fi
- else
- echo "::error title=Unsupported release event::Expected push or workflow_dispatch, got ${GITHUB_EVENT_NAME}."
- exit 1
- fi
-
- if [[ "$GITHUB_SHA" != "$TAG_COMMIT" ]]; then
- echo "::error title=Release source mismatch::Event commit ${GITHUB_SHA} does not match tag ${RELEASE_TAG} commit ${TAG_COMMIT}."
- exit 1
- fi
-
- if ! git rev-parse --verify "origin/main^{commit}" >/dev/null 2>&1; then
- echo "::error title=Missing main branch::Unable to resolve origin/main from the release checkout."
+ TAG_COMMIT=$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}")
+ HEAD_COMMIT=$(git rev-parse HEAD)
+ if [[ "$HEAD_COMMIT" != "$TAG_COMMIT" ]]; then
+ echo "::error title=Release source mismatch::Checked out ${HEAD_COMMIT}, expected tag commit ${TAG_COMMIT}."
exit 1
fi
if ! git merge-base --is-ancestor "$TAG_COMMIT" origin/main; then
- echo "::error title=Release tag not on main::Tag ${RELEASE_TAG} commit ${TAG_COMMIT} is not contained in origin/main."
+ echo "::error title=Release tag not on main::Tag ${RELEASE_TAG} is not contained in origin/main."
exit 1
fi
- VERSION="${RELEASE_TAG#v}"
+ git tag --merged origin/main --list 'v*' |
+ ruby script/validate_release_order.rb "$RELEASE_TAG"
+
{
echo "tag=$RELEASE_TAG"
- echo "version=$VERSION"
+ echo "version=${RELEASE_TAG#v}"
echo "commit=$TAG_COMMIT"
} >> "$GITHUB_OUTPUT"
- echo "Release tag: $RELEASE_TAG"
- echo "Release version: $VERSION"
- echo "Release commit: $TAG_COMMIT"
-
- - name: Checkout validated release commit
- env:
- RELEASE_COMMIT: ${{ steps.release_tag.outputs.commit }}
- run: |
- git checkout --detach "$RELEASE_COMMIT"
- HEAD_COMMIT=$(git rev-parse HEAD)
-
- if [[ "$HEAD_COMMIT" != "$RELEASE_COMMIT" ]]; then
- echo "::error title=Release checkout mismatch::Checked out ${HEAD_COMMIT}, expected ${RELEASE_COMMIT}."
- exit 1
- fi
- echo "Checked out release commit: $HEAD_COMMIT"
-
- - name: Check publishing secrets
+ - name: Check Sparkle signing key
env:
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
@@ -127,20 +83,16 @@ jobs:
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.5'
-
+
- name: Install Tuist
- run: |
- brew install tuist
-
+ run: brew install tuist
+
- name: Generate Xcode Project
env:
TUIST_APP_VERSION: ${{ steps.release_tag.outputs.version }}
run: |
- # 生成基于时间戳的构建号(格式:YYYYMMDDHHmm)
TUIST_BUILD_VERSION=$(date -u "+%Y%m%d%H%M")
export TUIST_BUILD_VERSION
- echo "App version set to: $TUIST_APP_VERSION"
- echo "Build number set to: $TUIST_BUILD_VERSION"
tuist generate --no-open
- name: Test
@@ -154,7 +106,7 @@ jobs:
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO
-
+
- name: Archive App
run: |
xcodebuild \
@@ -175,110 +127,62 @@ jobs:
- name: Verify Universal Binary
run: |
BINARY_PATH="DerivedData/Archives/TypeSwitch.xcarchive/Products/Applications/TypeSwitch.app/Contents/MacOS/TypeSwitch"
- ARCHS=$(lipo -archs "$BINARY_PATH")
- echo "TypeSwitch architectures: $ARCHS"
-
- if [[ " $ARCHS " != *" arm64 "* || " $ARCHS " != *" x86_64 "* ]]; then
- echo "::error title=Invalid binary architectures::Expected universal binary with arm64 and x86_64, got: $ARCHS"
+ ARCHITECTURES=$(lipo -archs "$BINARY_PATH")
+ if [[ " $ARCHITECTURES " != *" arm64 "* || " $ARCHITECTURES " != *" x86_64 "* ]]; then
+ echo "::error title=Invalid binary architectures::Expected arm64 and x86_64, got ${ARCHITECTURES}."
exit 1
fi
-
+
- name: Package App
run: |
mkdir -p DerivedData/Exports
ditto DerivedData/Archives/TypeSwitch.xcarchive/Products/Applications/TypeSwitch.app DerivedData/Exports/TypeSwitch.app
- cd DerivedData/Exports
- ditto -c -k --sequesterRsrc --keepParent TypeSwitch.app ../../TypeSwitch-macOS-universal.zip
+ ditto -c -k --sequesterRsrc --keepParent DerivedData/Exports/TypeSwitch.app TypeSwitch-macOS-universal.zip
- name: Prepare release notes
env:
RELEASE_TAG: ${{ steps.release_tag.outputs.tag }}
run: |
- if ! awk -v version="${RELEASE_TAG}" '
- $0 == "## " version {
- found = 1
- next
- }
- found && /^## / {
- exit
- }
- found {
- lines[++count] = $0
- }
- END {
- if (!found) {
- exit 2
- }
-
- start = 1
- while (start <= count && lines[start] ~ /^[[:space:]]*$/) {
- start++
- }
-
- end = count
- while (end >= start && lines[end] ~ /^[[:space:]]*$/) {
- end--
- }
-
- if (end < start) {
- exit 3
- }
-
- for (i = start; i <= end; i++) {
- print lines[i]
- }
- }
+ if ! awk -v heading="## ${RELEASE_TAG}" '
+ $0 == heading { found = 1; next }
+ found && /^## / { exit }
+ found { print }
+ END { if (!found) exit 2 }
' CHANGELOG.md > release-notes.md; then
- echo "::error title=Missing release notes::CHANGELOG.md must contain a non-empty ## ${RELEASE_TAG} section."
+ echo "::error title=Missing release notes::CHANGELOG.md does not contain a ## ${RELEASE_TAG} section."
exit 1
fi
- echo "Release notes for ${RELEASE_TAG}:"
- cat release-notes.md
-
- - name: Render Sparkle release notes HTML
- env:
- GH_TOKEN: ${{ github.token }}
- run: |
- gh api markdown \
- --method POST \
- -F text=@release-notes.md \
- -f mode=gfm \
- -f context="${GITHUB_REPOSITORY}" \
- > release-notes.html
-
- if [[ ! -s release-notes.html ]]; then
- echo "::error title=Missing rendered release notes::GitHub Markdown API returned an empty release-notes.html file."
+ if ! grep -q '[^[:space:]]' release-notes.md; then
+ echo "::error title=Missing release notes::CHANGELOG.md must contain a non-empty ## ${RELEASE_TAG} section."
exit 1
fi
- echo "Rendered Sparkle release notes HTML:"
- cat release-notes.html
-
- name: Generate Checksums
id: release_artifact
run: |
- SHA256=$(shasum -a 256 TypeSwitch-macOS-universal.zip | awk '{print $1}')
- echo "asset_name=TypeSwitch-macOS-universal.zip" >> "$GITHUB_OUTPUT"
+ ASSET_NAME="TypeSwitch-macOS-universal.zip"
+ SHA256=$(shasum -a 256 "$ASSET_NAME" | awk '{print $1}')
+ echo "asset_name=$ASSET_NAME" >> "$GITHUB_OUTPUT"
echo "sha256=$SHA256" >> "$GITHUB_OUTPUT"
{
echo "### SHA-256 Checksums"
echo '```'
- echo "$SHA256 TypeSwitch-macOS-universal.zip"
+ echo "$SHA256 $ASSET_NAME"
echo '```'
} > checksums.txt
{
echo "## TypeSwitch ${{ steps.release_tag.outputs.tag }}"
- echo ""
+ echo
cat release-notes.md
- echo ""
+ echo
echo "### Build"
- echo ""
+ echo
echo "- Universal Binary (Apple Silicon + Intel)"
echo "- macOS 14.0+"
- echo ""
+ echo
cat checksums.txt
} > release-body.md
@@ -286,7 +190,7 @@ jobs:
run: |
SPARKLE_ARCHIVE="Sparkle-2.9.4.tar.xz"
EXPECTED_SHA256="ce89daf967db1e1893ed3ebd67575ed82d3902563e3191ca92aaec9164fbdef9"
- curl -fsSL -o "$SPARKLE_ARCHIVE" https://github.com/sparkle-project/Sparkle/releases/download/2.9.4/Sparkle-2.9.4.tar.xz
+ curl -fsSL -o "$SPARKLE_ARCHIVE" "https://github.com/sparkle-project/Sparkle/releases/download/2.9.4/$SPARKLE_ARCHIVE"
ACTUAL_SHA256=$(shasum -a 256 "$SPARKLE_ARCHIVE" | awk '{print $1}')
if [[ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]]; then
@@ -304,7 +208,7 @@ jobs:
run: |
mkdir -p DerivedData/SparkleFeed
cp TypeSwitch-macOS-universal.zip DerivedData/SparkleFeed/
- cp release-notes.html DerivedData/SparkleFeed/TypeSwitch-macOS-universal.html
+ cp release-notes.md DerivedData/SparkleFeed/TypeSwitch-macOS-universal.md
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" | ./sparkle-tools/bin/generate_appcast \
--ed-key-file - \
@@ -316,19 +220,16 @@ jobs:
cp DerivedData/SparkleFeed/appcast.xml appcast.xml
- - name: Verify local release artifacts
+ - name: Verify release artifacts
env:
RELEASE_TAG: ${{ steps.release_tag.outputs.tag }}
- run: script/verify_release.sh local "$RELEASE_TAG" .
-
- - name: Test release scripts
- run: script/test_release_scripts.sh
+ run: script/verify_release.sh "$RELEASE_TAG" .
- name: Attest release artifact
uses: actions/attest@v4
with:
subject-path: TypeSwitch-macOS-universal.zip
-
+
- name: Release
uses: softprops/action-gh-release@v2
with:
@@ -339,29 +240,18 @@ jobs:
body_path: release-body.md
draft: false
prerelease: false
- generate_release_notes: true
- overwrite_files: true
+ overwrite_files: false
tag_name: ${{ steps.release_tag.outputs.tag }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Record publish failure
- if: ${{ failure() }}
- run: |
- {
- echo "### Release distribution status"
- echo
- echo '- Status: `publish_failed`'
- echo "- Tag: \`${{ steps.release_tag.outputs.tag || github.ref_name }}\`"
- echo "- Details: The publish job did not complete successfully."
- } >> "$GITHUB_STEP_SUMMARY"
-
- sync_homebrew:
- name: Sync Homebrew cask
+ homebrew:
+ name: Update Homebrew cask
needs: publish
runs-on: ubuntu-latest
permissions:
contents: read
+
steps:
- name: Checkout release scripts
uses: actions/checkout@v4
@@ -412,24 +302,3 @@ jobs:
git commit -m "Update TypeSwitch cask to ${RELEASE_TAG}"
git push
-
- verify_distribution:
- name: Verify published distribution
- needs: [publish, sync_homebrew]
- if: ${{ always() && needs.publish.result == 'success' }}
- runs-on: macos-26
- permissions:
- contents: read
- attestations: read
- steps:
- - name: Checkout release scripts
- uses: actions/checkout@v4
- with:
- ref: ${{ needs.publish.outputs.commit }}
-
- - name: Verify GitHub Release assets and attestation
- env:
- EXPECTED_SHA256: ${{ needs.publish.outputs.sha256 }}
- GH_TOKEN: ${{ github.token }}
- RELEASE_TAG: ${{ needs.publish.outputs.tag }}
- run: script/verify_release.sh remote "$RELEASE_TAG" "$GITHUB_REPOSITORY" "$EXPECTED_SHA256"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 356cded..cef4d8c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@
- 取消启动时的静默更新检查,确保只有用户主动点击“检查更新…”时才会访问 GitHub。
- 修复恢复已忽略 App 时丢失原输入法策略的问题,并为菜单栏图标补充当前 App 状态的 VoiceOver 描述。
- 修复输入法目录加载期间激活 App 时可能漏掉自动切换的问题;目录加载完成后仅补偿处理此前被跳过的当前 App。
+- 修复手动切换到正确输入法后仍保留失败警告的问题,并让 VoiceOver 在输入法异常时优先播报警告状态。
+- 加固发布链路:阻止版本回退和跨版本并发,提前验证 Homebrew 写权限,并校验 Sparkle 签名与 App 内置公钥一致。
### 🇺🇸 English
@@ -25,6 +27,8 @@
- Removed the silent update check at startup so TypeSwitch contacts GitHub for updates only after the user clicks `Check for Updates…`.
- Fixed restored ignored apps losing their previous input method strategy, and added VoiceOver descriptions for the current app state in the menu bar icon.
- Fixed automatic switching being skipped when an app activates while the input method catalog is loading; TypeSwitch now retries only the current app whose activation was deferred.
+- Fixed stale failure warnings after manually selecting the correct input method, and made VoiceOver announce input method warnings before the current app state.
+- Hardened releases against version rollback and cross-version races, added an early Homebrew write-access check, and verified Sparkle signatures against the public key embedded in the app.
## v0.9.0
diff --git a/Documentation/CodeReview-2026-08-17.md b/Documentation/CodeReview-2026-08-17.md
index 6fc6764..abdc0ff 100644
--- a/Documentation/CodeReview-2026-08-17.md
+++ b/Documentation/CodeReview-2026-08-17.md
@@ -1,36 +1,54 @@
# 未提交改动审查记录(2026-08-17)
-本记录覆盖输入法切换补偿、支持诊断与发布链路相关改动。当前完整 macOS XCTest 和发布脚本测试均通过,但以下问题应在 PR 合并前处理。
+本记录覆盖输入法切换补偿、支持诊断与发布链路相关改动。2026-08-19 已处理发布降级 P1;以下 P2/P3 仅记录,尚未修复。
## 待处理问题
-### P1:发布前未验证 Homebrew 凭据
+### P2:诊断信息缺少 Locale
-`.github/workflows/release.yml` 的发布前检查只验证 `SPARKLE_ED_PRIVATE_KEY`。如果 `HOMEBREW_TAP_TOKEN` 缺失、失效或没有 tap 写权限,GitHub Release 和 attestation 已公开后,Homebrew 同步才会失败,形成半完成发行。
+`SupportDiagnostics` 已使用 `Bundle.main.preferredLocalizations.first` 报告 App 界面语言,但没有同时记录 `Locale.current.identifier`。区域格式、排序或界面语言与地区不一致的问题仍缺少必要上下文。
-建议在公开 Release 前检查 token 非空,并通过只读 GitHub API 请求确认其对 `ygsgdbd/homebrew-tap` 具备 push 权限。
+建议分别输出 `App Language` 和 `Locale`,并增加二者不同及 App language 缺失时的测试。
-### P1:不同版本的发布任务仍可并发
+### P2:同 tag rerun 可能让 Release 正文与资产不一致
-release concurrency group 当前包含 tag,因此只能阻止同一 tag 并发。不同 tag 可以同时修改全局 latest Release 和同一个 Homebrew cask;如果旧版本后完成,可能覆盖或降级新版本的 cask。
+`.github/workflows/release.yml` 使用时间戳 build 重新构建产物,同时配置 `overwrite_files: false`。如果同一 tag 的首次运行已上传资产后失败,rerun 会生成新的 ZIP、checksum 和 `release-body.md`;action 会跳过同名资产,但仍可能更新 Release 正文,造成正文 checksum 指向新 ZIP、实际下载仍是旧 ZIP。
-建议让同一仓库的所有发布共用一个 concurrency group,使完整发布链串行执行,并保持 `cancel-in-progress: false`。
+建议后续在构建前检测该 tag 的 GitHub Release 是否已存在并直接失败,避免对不可变 Release 做同 tag 重建。
-### P2:诊断信息缺少 Locale
+### P2:手动切回失败目标会重新显示旧警告
-`SupportDiagnostics` 已使用 `Bundle.main.preferredLocalizations.first` 报告 App 界面语言,但没有同时记录 `Locale.current.identifier`。区域格式、排序或界面语言与地区不一致的问题仍缺少必要上下文。
+`AppFeature.swift` 的确认选择流程在更新 `followLast` 前判断是否清理失败记录。场景“自动切换 A 失败 → 手动切到 B → 手动切回 A”中,旧失败会再次成为当前目标并重新显示。
-建议分别输出 `App Language` 和 `Locale`,并增加二者不同及 App language 缺失时的测试。
+建议先更新 `followLast` 再清理,或在确认选择匹配失败目标时直接清理,并增加对应回归测试。
+
+### P2:VoiceOver 警告覆盖当前 App 状态
-### P2:Homebrew updater 测试未覆盖 SHA 和 URL 替换
+`AppFeature+MenuState.swift` 在存在输入法异常时只返回通用 warning,丢失原有的 configured、unconfigured 或 ignored 状态,与“先播报警告,再播报当前 App 状态”的发布说明不一致。
-`script/test_release_scripts.sh` 的 updater fixture 只把 version 改为旧值,SHA 和 URL 原本就是正确值,也没有逐项断言第一次更新后的结果。如果 updater 以后不再替换 SHA 或 URL,测试仍会通过。
+建议组合“警告 + 当前状态”,并覆盖异常下 configured/unconfigured 状态及播报顺序。
+
+### P3:Homebrew updater 测试未断言首次更新结果
+
+`script/test_release_scripts.sh` 的 updater fixture 已将 version、SHA 和 URL 设为旧值,但第一次运行后直接把任意结果保存为预期内容,只验证第二次运行幂等。如果 updater 以后不再替换 SHA 或 URL,测试仍会通过。
建议让 version、sha256 和 url 三个字段全部使用旧值,并逐项断言更新后的目标内容和第二次运行的幂等性。
+### P3:发布说明仍描述已删除的 Homebrew 权限预检
+
+`CHANGELOG.md` 仍宣称发布前会验证 Homebrew token 写权限,但当前简化流程明确以 GitHub Release 为发布事实来源,Homebrew 允许短暂延迟,token 只在 Homebrew checkout 中使用。本记录此前的同类“已处理”陈述也已不再成立。
+
+建议后续同步修订 `CHANGELOG.md`,保留“阻止版本回退”的描述,删除 Homebrew 权限预检已完成的表述。
+
+## 已处理(2026-08-19)
+
+- 发布前要求当前 tag 是 `origin/main` 上最高的严格 SemVer,阻止低版本 GitHub Release 成为 latest。
+- Homebrew updater 独立拒绝版本降级,同版本保持幂等。
+- 所有版本共用仓库级 release concurrency group,并使用 `queue: max`。
+
## 已验证
-- 重新生成 Tuist 工程后的完整 macOS XCTest:126 tests,0 failures。
+- 重新生成 Tuist 工程后的完整 macOS XCTest:128 tests,0 failures。
- `script/test_release_scripts.sh`:通过。
- Shell、Ruby、workflow YAML 和 `git diff --check`:通过。
- SwiftFormat 0.62.1:0 个文件需要格式化。
diff --git a/TypeSwitch/Resources/Base.lproj/Localizable.strings b/TypeSwitch/Resources/Base.lproj/Localizable.strings
index 62a7c03..8110774 100644
--- a/TypeSwitch/Resources/Base.lproj/Localizable.strings
+++ b/TypeSwitch/Resources/Base.lproj/Localizable.strings
@@ -4,6 +4,7 @@
"menu.accessibility_configured" = "TypeSwitch,当前 App 已配置";
"menu.accessibility_unconfigured" = "TypeSwitch,当前 App 未配置";
"menu.accessibility_ignored" = "TypeSwitch,当前 App 已忽略";
+"menu.accessibility_warning" = "TypeSwitch,输入法状态警告";
// 通用
"common.cancel" = "取消";
diff --git a/TypeSwitch/Resources/en.lproj/Localizable.strings b/TypeSwitch/Resources/en.lproj/Localizable.strings
index bcfce8d..4d7d369 100644
--- a/TypeSwitch/Resources/en.lproj/Localizable.strings
+++ b/TypeSwitch/Resources/en.lproj/Localizable.strings
@@ -4,6 +4,7 @@
"menu.accessibility_configured" = "TypeSwitch, current app configured";
"menu.accessibility_unconfigured" = "TypeSwitch, current app unconfigured";
"menu.accessibility_ignored" = "TypeSwitch, current app ignored";
+"menu.accessibility_warning" = "TypeSwitch, input method warning";
// Common
"common.cancel" = "Cancel";
diff --git a/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings b/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings
index 62a7c03..8110774 100644
--- a/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings
+++ b/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings
@@ -4,6 +4,7 @@
"menu.accessibility_configured" = "TypeSwitch,当前 App 已配置";
"menu.accessibility_unconfigured" = "TypeSwitch,当前 App 未配置";
"menu.accessibility_ignored" = "TypeSwitch,当前 App 已忽略";
+"menu.accessibility_warning" = "TypeSwitch,输入法状态警告";
// 通用
"common.cancel" = "取消";
diff --git a/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings b/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings
index 389c6cf..8c075e3 100644
--- a/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings
+++ b/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings
@@ -4,6 +4,7 @@
"menu.accessibility_configured" = "TypeSwitch,目前 App 已設定";
"menu.accessibility_unconfigured" = "TypeSwitch,目前 App 未設定";
"menu.accessibility_ignored" = "TypeSwitch,目前 App 已忽略";
+"menu.accessibility_warning" = "TypeSwitch,輸入法狀態警告";
// 通用
"common.cancel" = "取消";
diff --git a/TypeSwitch/Sources/App/AppFeature+MenuState.swift b/TypeSwitch/Sources/App/AppFeature+MenuState.swift
index c36e109..0e6a369 100644
--- a/TypeSwitch/Sources/App/AppFeature+MenuState.swift
+++ b/TypeSwitch/Sources/App/AppFeature+MenuState.swift
@@ -41,6 +41,10 @@ extension AppFeature.State {
}
var menuBarAccessibilityLabel: String {
+ if inputMethodDiagnostic != nil {
+ return TypeSwitchStrings.Menu.accessibilityWarning
+ }
+
guard let currentFrontmostBundleId else {
return "TypeSwitch"
}
@@ -384,7 +388,7 @@ extension AppFeature.State {
inputMethods.first(where: { $0.id == inputMethodId })?.name
}
- private func isCurrentTarget(_ attempt: LastSwitchAttempt) -> Bool {
+ func isCurrentTarget(_ attempt: LastSwitchAttempt) -> Bool {
let appStrategy = strategy(for: attempt.bundleId)
switch attempt.ruleSource {
case .app:
diff --git a/TypeSwitch/Sources/App/AppFeature.swift b/TypeSwitch/Sources/App/AppFeature.swift
index ff95707..68fd1e0 100644
--- a/TypeSwitch/Sources/App/AppFeature.swift
+++ b/TypeSwitch/Sources/App/AppFeature.swift
@@ -298,6 +298,15 @@ struct AppFeature {
return .none
}
+ if let lastSwitchAttempt = state.lastSwitchAttempt,
+ lastSwitchAttempt.bundleId == bundleId,
+ lastSwitchAttempt.inputMethodId == inputMethodId,
+ case .failed = lastSwitchAttempt.outcome,
+ state.isCurrentTarget(lastSwitchAttempt)
+ {
+ state.lastSwitchAttempt = nil
+ }
+
if case .followLast(let previousInputMethodId) = state.appRules[bundleId]?.strategy {
guard previousInputMethodId != inputMethodId else {
return .none
diff --git a/TypeSwitchTests/AppFeatureTests.swift b/TypeSwitchTests/AppFeatureTests.swift
index 4588487..78fea6f 100644
--- a/TypeSwitchTests/AppFeatureTests.swift
+++ b/TypeSwitchTests/AppFeatureTests.swift
@@ -2731,6 +2731,46 @@ final class AppFeatureTests: XCTestCase {
XCTAssertEqual(state.menuBarAccessibilityLabel, TypeSwitchStrings.Menu.accessibilityIgnored)
}
+ func testMenuBarAccessibilityLabelPrioritizesInputMethodDiagnostics() {
+ let bundleId = "com.test.chat"
+ let failedAttempt = AppFeature.State.LastSwitchAttempt(
+ appName: "Chat",
+ bundleId: bundleId,
+ inputMethodId: "ime.zh",
+ inputMethodName: "Pinyin",
+ outcome: .failed(.failedToSwitchInputMethod("ime.zh")),
+ ruleSource: .app,
+ timestamp: Date(timeIntervalSince1970: 10)
+ )
+
+ var catalogEmptyState = AppFeature.State()
+ catalogEmptyState.inputMethodCatalogStatus = .ready
+
+ var catalogFailedState = AppFeature.State()
+ catalogFailedState.inputMethodCatalogStatus = .failed(.failedToFetchInputMethods)
+
+ var switchFailedState = AppFeature.State()
+ switchFailedState.currentFrontmostBundleId = bundleId
+ switchFailedState.inputMethodCatalogStatus = .ready
+ switchFailedState.inputMethods = [InputMethod(id: "ime.zh", name: "Pinyin")]
+ switchFailedState.lastSwitchAttempt = failedAttempt
+ switchFailedState.$appRulesStore.withLock {
+ $0.rules[bundleId] = AppRuleRecord(
+ bundleId: bundleId,
+ lastKnownPath: "/Applications/Chat.app",
+ lastKnownName: "Chat",
+ strategy: .fixed(inputMethodId: "ime.zh"),
+ createdAt: Date(timeIntervalSince1970: 10),
+ updatedAt: Date(timeIntervalSince1970: 10)
+ )
+ }
+
+ for state in [catalogEmptyState, catalogFailedState, switchFailedState] {
+ XCTAssertNotNil(state.inputMethodDiagnostic)
+ XCTAssertEqual(state.menuBarAccessibilityLabel, TypeSwitchStrings.Menu.accessibilityWarning)
+ }
+ }
+
func testFollowLastWithoutRecordShowsEmptyMenuOption() {
let app = AppInfo(bundleId: "com.test.chat", name: "Chat", path: "/Applications/Chat.app")
@@ -2868,6 +2908,51 @@ final class AppFeatureTests: XCTestCase {
}
}
+ func testManualSelectionOfFailedTargetClearsDiagnosticWithoutIncrementingStatistics() async {
+ let bundleId = "com.test.chat"
+ let targetInputMethod = "ime.zh"
+
+ var initialState = AppFeature.State()
+ initialState.currentFrontmostBundleId = bundleId
+ initialState.inputMethodCatalogStatus = .ready
+ initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "Pinyin")]
+ initialState.lastSwitchAttempt = .init(
+ appName: "Chat",
+ bundleId: bundleId,
+ inputMethodId: targetInputMethod,
+ inputMethodName: "Pinyin",
+ outcome: .failed(.failedToSwitchInputMethod(targetInputMethod)),
+ ruleSource: .app,
+ timestamp: Date(timeIntervalSince1970: 10)
+ )
+ initialState.$appRulesStore.withLock {
+ $0.rules[bundleId] = AppRuleRecord(
+ bundleId: bundleId,
+ lastKnownPath: "/Applications/Chat.app",
+ lastKnownName: "Chat",
+ strategy: .fixed(inputMethodId: targetInputMethod),
+ createdAt: Date(timeIntervalSince1970: 10),
+ updatedAt: Date(timeIntervalSince1970: 10)
+ )
+ }
+ initialState.$appSwitchStatisticsStore.withLock {
+ $0.counts[bundleId] = 2
+ }
+
+ let store = TestStore(initialState: initialState) {
+ AppFeature()
+ }
+
+ XCTAssertEqual(store.state.inputMethodDiagnostic?.kind, .switchFailed)
+
+ await store.send(.system(.inputMethodSelectedChanged(targetInputMethod))) {
+ $0.lastSwitchAttempt = nil
+ }
+
+ XCTAssertNil(store.state.inputMethodDiagnostic)
+ XCTAssertEqual(store.state.appSwitchStatisticsStore.counts[bundleId], 2)
+ }
+
func testManualSelectionDoesNotUpdateFallbackFollowLastWhenAppRuleIsNone() async {
let bundleId = "com.test.chat"
diff --git a/TypeSwitchTests/LocalizationStringsTests.swift b/TypeSwitchTests/LocalizationStringsTests.swift
index a1d96e5..dd1cb4b 100644
--- a/TypeSwitchTests/LocalizationStringsTests.swift
+++ b/TypeSwitchTests/LocalizationStringsTests.swift
@@ -45,6 +45,7 @@ final class LocalizationStringsTests: XCTestCase {
"menu.accessibility_configured": "TypeSwitch,当前 App 已配置",
"menu.accessibility_unconfigured": "TypeSwitch,当前 App 未配置",
"menu.accessibility_ignored": "TypeSwitch,当前 App 已忽略",
+ "menu.accessibility_warning": "TypeSwitch,输入法状态警告",
"settings.general.auto_launch": "登录时打开",
"settings.general.auto_launch_requires_approval": "请前往“系统设置”>“通用”>“登录项”允许 TypeSwitch。",
"settings.general.open_login_items": "打开登录项设置",
@@ -72,6 +73,7 @@ final class LocalizationStringsTests: XCTestCase {
"menu.accessibility_configured": "TypeSwitch,当前 App 已配置",
"menu.accessibility_unconfigured": "TypeSwitch,当前 App 未配置",
"menu.accessibility_ignored": "TypeSwitch,当前 App 已忽略",
+ "menu.accessibility_warning": "TypeSwitch,输入法状态警告",
"settings.general.auto_launch": "登录时打开",
"settings.general.auto_launch_requires_approval": "请前往“系统设置”>“通用”>“登录项”允许 TypeSwitch。",
"settings.general.open_login_items": "打开登录项设置",
@@ -99,6 +101,7 @@ final class LocalizationStringsTests: XCTestCase {
"menu.accessibility_configured": "TypeSwitch,目前 App 已設定",
"menu.accessibility_unconfigured": "TypeSwitch,目前 App 未設定",
"menu.accessibility_ignored": "TypeSwitch,目前 App 已忽略",
+ "menu.accessibility_warning": "TypeSwitch,輸入法狀態警告",
"settings.general.auto_launch": "登入時開啟",
"settings.general.auto_launch_requires_approval": "請前往「系統設定」>「一般」>「登入項目」允許 TypeSwitch。",
"settings.general.open_login_items": "開啟登入項目設定",
@@ -126,6 +129,7 @@ final class LocalizationStringsTests: XCTestCase {
"menu.accessibility_configured": "TypeSwitch, current app configured",
"menu.accessibility_unconfigured": "TypeSwitch, current app unconfigured",
"menu.accessibility_ignored": "TypeSwitch, current app ignored",
+ "menu.accessibility_warning": "TypeSwitch, input method warning",
"settings.general.auto_launch": "Launch at Login",
"settings.general.auto_launch_requires_approval": "Approve TypeSwitch in System Settings > General > Login Items.",
"settings.general.open_login_items": "Open Login Items Settings",
diff --git a/script/test_release_scripts.sh b/script/test_release_scripts.sh
index 089a81a..2dd39f2 100755
--- a/script/test_release_scripts.sh
+++ b/script/test_release_scripts.sh
@@ -5,6 +5,7 @@ set -euo pipefail
ROOT_DIR=$(cd "$(dirname "$0")/.." && pwd)
VERIFY_SCRIPT="${ROOT_DIR}/script/verify_release.sh"
CASK_SCRIPT="${ROOT_DIR}/script/update_homebrew_cask.rb"
+ORDER_SCRIPT="${ROOT_DIR}/script/validate_release_order.rb"
TEST_DIR=$(mktemp -d)
trap 'rm -rf "$TEST_DIR"' EXIT
@@ -19,6 +20,16 @@ assert_fails() {
fi
}
+assert_contains() {
+ grep -Fq -- "$2" "$1" || fail "$1 is missing: $2"
+}
+
+assert_not_contains() {
+ if grep -Fq -- "$2" "$1"; then
+ fail "$1 must not contain: $2"
+ fi
+}
+
sha256_file() {
shasum -a 256 "$1" | awk '{print $1}'
}
@@ -32,10 +43,11 @@ make_artifacts() {
local appcast_build="${FIXTURE_APPCAST_BUILD:-$build}"
local minimum="${FIXTURE_MINIMUM:-14.0}"
local feed_url="${FIXTURE_FEED_URL:-https://github.com/ygsgdbd/TypeSwitch/releases/latest/download/appcast.xml}"
- local signature="${FIXTURE_SIGNATURE-test-signature}"
+ local public_key="${FIXTURE_PUBLIC_KEY:-cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHA=}"
+ local signature="${FIXTURE_SIGNATURE-valid-test-signature}"
local appcast_layout="${FIXTURE_APPCAST_LAYOUT:-child}"
local staging="${TEST_DIR}/staging"
- local sha size version_elements enclosure_version_attributes
+ local sha size appcast_url appcast_size version_elements enclosure_version_attributes
rm -rf "$dir" "$staging"
mkdir -p "$dir" "${staging}/TypeSwitch.app/Contents/MacOS"
@@ -49,13 +61,18 @@ make_artifacts() {
CFBundleVersion${build}
LSMinimumSystemVersion${minimum}
SUFeedURL${feed_url}
+ SUPublicEDKey${public_key}
EOF
+
ditto -c -k --keepParent "${staging}/TypeSwitch.app" "${dir}/TypeSwitch-macOS-universal.zip"
sha=$(sha256_file "${dir}/TypeSwitch-macOS-universal.zip")
size=$(stat -f '%z' "${dir}/TypeSwitch-macOS-universal.zip")
+ appcast_url="${FIXTURE_APPCAST_URL:-https://github.com/ygsgdbd/TypeSwitch/releases/download/${tag}/TypeSwitch-macOS-universal.zip}"
+ appcast_size="${FIXTURE_APPCAST_SIZE:-$size}"
printf '### SHA-256 Checksums\n```\n%s TypeSwitch-macOS-universal.zip\n```\n' "$sha" > "${dir}/checksums.txt"
+
if [[ "$appcast_layout" == "attributes" ]]; then
version_elements=""
enclosure_version_attributes=" sparkle:version=\"${appcast_build}\" sparkle:shortVersionString=\"${version}\""
@@ -63,13 +80,14 @@ EOF
version_elements="${appcast_build}${version}"
enclosure_version_attributes=""
fi
+
cat > "${dir}/appcast.xml" <
-
${version_elements}
-
+
@@ -83,138 +101,123 @@ printf '%s\n' "${LIPO_ARCHS:-arm64 x86_64}"
EOF
chmod +x "${TEST_DIR}/bin/lipo"
+cat > "${TEST_DIR}/bin/sign_update" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+legacy_key=$(cat)
+expected_key=$(ruby -rbase64 -e 'STDOUT.write(Base64.strict_encode64(("\0" * 64) + ("p" * 32)))')
+[[ "$legacy_key" == "$expected_key" ]] || exit 1
+[[ "$1" == "--ed-key-file" && "$2" == "-" && "$3" == "--verify" ]] || exit 1
+[[ -f "$4" && "$5" == "valid-test-signature" ]] || exit 1
+EOF
+chmod +x "${TEST_DIR}/bin/sign_update"
+export SPARKLE_SIGN_UPDATE="${TEST_DIR}/bin/sign_update"
+
ARTIFACTS="${TEST_DIR}/artifacts"
make_artifacts "$ARTIFACTS" v1.2.3
-PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS" >/dev/null
+PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" v1.2.3 "$ARTIFACTS" >/dev/null
+
FIXTURE_APPCAST_LAYOUT=attributes make_artifacts "$ARTIFACTS" v1.2.3
-PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS" >/dev/null
-make_artifacts "$ARTIFACTS" v1.2.3
-assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local 1.2.3 "$ARTIFACTS"
+PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" v1.2.3 "$ARTIFACTS" >/dev/null
-printf 'corruption\n' >> "${ARTIFACTS}/TypeSwitch-macOS-universal.zip"
-FAILED_SUMMARY="${TEST_DIR}/failed-summary.md"
-if GITHUB_STEP_SUMMARY="$FAILED_SUMMARY" PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS" >/dev/null 2>&1; then
- fail "Checksum corruption unexpectedly passed verification."
+assert_fails "$VERIFY_SCRIPT" 1.2.3 "$ARTIFACTS"
+assert_fails "$VERIFY_SCRIPT" v01.2.3 "$ARTIFACTS"
+
+printf '%s\n' v1.9.0 v1.10.0 v01.99.0 invalid | ruby "$ORDER_SCRIPT" v1.10.0 >/dev/null
+if printf '%s\n' v1.9.0 v1.10.0 | ruby "$ORDER_SCRIPT" v1.9.0 >/dev/null 2>&1; then
+ fail "Release order validation accepted a lower version."
+fi
+if printf '%s\n' v1.2.3 | ruby "$ORDER_SCRIPT" v01.2.3 >/dev/null 2>&1; then
+ fail "Release order validation accepted a non-strict current tag."
fi
-grep -q '`publish_failed`' "$FAILED_SUMMARY" || fail "Local failure did not report publish_failed."
+
+make_artifacts "$ARTIFACTS" v1.2.3
+printf 'corruption\n' >> "${ARTIFACTS}/TypeSwitch-macOS-universal.zip"
+assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" v1.2.3 "$ARTIFACTS"
FIXTURE_PLIST_VERSION=1.2.4 make_artifacts "$ARTIFACTS" v1.2.3
-assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS"
+assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" v1.2.3 "$ARTIFACTS"
FIXTURE_BUILD= make_artifacts "$ARTIFACTS" v1.2.3
-assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS"
+assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" v1.2.3 "$ARTIFACTS"
FIXTURE_MINIMUM=13.0 make_artifacts "$ARTIFACTS" v1.2.3
-assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS"
+assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" v1.2.3 "$ARTIFACTS"
FIXTURE_FEED_URL=https://example.invalid/appcast.xml make_artifacts "$ARTIFACTS" v1.2.3
-assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS"
+assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" v1.2.3 "$ARTIFACTS"
+
make_artifacts "$ARTIFACTS" v1.2.3
-assert_fails env PATH="${TEST_DIR}/bin:${PATH}" LIPO_ARCHS=arm64 "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS"
+assert_fails env PATH="${TEST_DIR}/bin:${PATH}" LIPO_ARCHS=arm64 "$VERIFY_SCRIPT" v1.2.3 "$ARTIFACTS"
FIXTURE_APPCAST_BUILD=wrong-build make_artifacts "$ARTIFACTS" v1.2.3
-assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS"
+assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" v1.2.3 "$ARTIFACTS"
+FIXTURE_APPCAST_URL=https://example.invalid/TypeSwitch.zip make_artifacts "$ARTIFACTS" v1.2.3
+assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" v1.2.3 "$ARTIFACTS"
+FIXTURE_APPCAST_SIZE=1 make_artifacts "$ARTIFACTS" v1.2.3
+assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" v1.2.3 "$ARTIFACTS"
FIXTURE_SIGNATURE= make_artifacts "$ARTIFACTS" v1.2.3
-assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS"
-make_artifacts "$ARTIFACTS" v1.2.3
+assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" v1.2.3 "$ARTIFACTS"
+FIXTURE_SIGNATURE=wrong-signature make_artifacts "$ARTIFACTS" v1.2.3
+assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" v1.2.3 "$ARTIFACTS"
+FIXTURE_PUBLIC_KEY=eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHg= make_artifacts "$ARTIFACTS" v1.2.3
+assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" v1.2.3 "$ARTIFACTS"
+make_artifacts "$ARTIFACTS" v1.2.3
CASK="${TEST_DIR}/typeswitch.rb"
SHA=$(sha256_file "${ARTIFACTS}/TypeSwitch-macOS-universal.zip")
cat > "$CASK" < "${TEST_DIR}/bin/gh" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-if [[ "$1" == "api" && "$2" == "repos/ygsgdbd/TypeSwitch/releases/tags/v1.2.3" ]]; then
- printf '{"tag_name":"v1.2.3","draft":false,"prerelease":false}\n'
-elif [[ "$1 $2" == "release download" ]]; then
- shift 2
- while [[ $# -gt 0 ]]; do
- case "$1" in
- --dir) destination="$2"; shift 2 ;;
- --pattern) pattern="$2"; shift 2 ;;
- *) shift ;;
- esac
- done
- cp "${GH_FIXTURE_DIR}/${pattern}" "$destination/"
-elif [[ "$1 $2" == "attestation verify" ]]; then
- exit 0
-elif [[ "$1" == "api" && "${*: -1}" == "repos/ygsgdbd/homebrew-tap/contents/Casks/typeswitch.rb" ]]; then
- cat "$GH_CASK_PATH"
-else
- echo "Unexpected gh invocation: $*" >&2
- exit 1
-fi
-EOF
-cat > "${TEST_DIR}/bin/curl" <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-while [[ $# -gt 0 ]]; do
- case "$1" in
- -o) destination="$2"; shift 2 ;;
- *) shift ;;
- esac
-done
-cp "$CURL_APPCAST_PATH" "$destination"
-EOF
-chmod +x "${TEST_DIR}/bin/gh" "${TEST_DIR}/bin/curl"
-
-REMOTE_SUMMARY="${TEST_DIR}/remote-summary.md"
-GITHUB_STEP_SUMMARY="$REMOTE_SUMMARY" \
- GH_FIXTURE_DIR="$ARTIFACTS" \
- GH_CASK_PATH="$CASK" \
- CURL_APPCAST_PATH="${ARTIFACTS}/appcast.xml" \
- PATH="${TEST_DIR}/bin:${PATH}" \
- "$VERIFY_SCRIPT" remote v1.2.3 ygsgdbd/TypeSwitch "$SHA" >/dev/null
-grep -q '`published_and_synced`' "$REMOTE_SUMMARY" || fail "Remote success did not report published_and_synced."
-
-assert_fails env \
- GH_FIXTURE_DIR="$ARTIFACTS" GH_CASK_PATH="$CASK" CURL_APPCAST_PATH="${ARTIFACTS}/appcast.xml" \
- PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" remote v1.2.3 ygsgdbd/TypeSwitch "$(printf '0%.0s' {1..64})"
-
-BAD_LATEST="${TEST_DIR}/bad-latest.xml"
-sed 's/1.2.3/1.2.2/' "${ARTIFACTS}/appcast.xml" > "$BAD_LATEST"
-assert_fails env \
- GH_FIXTURE_DIR="$ARTIFACTS" GH_CASK_PATH="$CASK" CURL_APPCAST_PATH="$BAD_LATEST" \
- PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" remote v1.2.3 ygsgdbd/TypeSwitch "$SHA"
-
-BAD_CASK="${TEST_DIR}/bad-typeswitch.rb"
-sed 's/version "1.2.3"/version "1.2.2"/' "$CASK" > "$BAD_CASK"
-PENDING_SUMMARY="${TEST_DIR}/pending-summary.md"
-if GITHUB_STEP_SUMMARY="$PENDING_SUMMARY" \
- GH_FIXTURE_DIR="$ARTIFACTS" GH_CASK_PATH="$BAD_CASK" CURL_APPCAST_PATH="${ARTIFACTS}/appcast.xml" \
- PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" remote v1.2.3 ygsgdbd/TypeSwitch "$SHA" >/dev/null 2>&1; then
- fail "Outdated Homebrew cask unexpectedly passed verification."
-fi
-grep -q '`published_homebrew_pending`' "$PENDING_SUMMARY" || fail "Homebrew mismatch did not report published_homebrew_pending."
-
-UPDATED_CASK="${TEST_DIR}/update-typeswitch.rb"
-sed 's/version "1.2.3"/version "0.1.0"/' "$CASK" > "$UPDATED_CASK"
-ruby "$CASK_SCRIPT" "$UPDATED_CASK" v1.2.3 "$SHA" >/dev/null
-EXPECTED_CASK=$(cat "$UPDATED_CASK")
-ruby "$CASK_SCRIPT" "$UPDATED_CASK" v1.2.3 "$SHA" >/dev/null
-[[ "$(cat "$UPDATED_CASK")" == "$EXPECTED_CASK" ]] || fail "Cask updater is not idempotent."
-assert_fails ruby "$CASK_SCRIPT" "$UPDATED_CASK" 1.2.3 "$SHA"
-printf ' version "9.9.9"\n' >> "$UPDATED_CASK"
-assert_fails ruby "$CASK_SCRIPT" "$UPDATED_CASK" v1.2.3 "$SHA"
+ruby "$CASK_SCRIPT" "$CASK" v1.2.4 "$SHA" >/dev/null
+EXPECTED_CASK=$(cat "$CASK")
+ruby "$CASK_SCRIPT" "$CASK" v1.2.4 "$SHA" >/dev/null
+[[ "$(cat "$CASK")" == "$EXPECTED_CASK" ]] || fail "Cask updater is not idempotent."
+assert_fails ruby "$CASK_SCRIPT" "$CASK" v1.2.3 "$SHA"
+[[ "$(cat "$CASK")" == "$EXPECTED_CASK" ]] || fail "Cask updater modified the file during a rejected downgrade."
+assert_fails ruby "$CASK_SCRIPT" "$CASK" 1.2.3 "$SHA"
+printf ' version "9.9.9"\n' >> "$CASK"
+assert_fails ruby "$CASK_SCRIPT" "$CASK" v1.2.3 "$SHA"
WORKFLOW="${ROOT_DIR}/.github/workflows/release.yml"
PR_WORKFLOW="${ROOT_DIR}/.github/workflows/pr-checks.yml"
JUSTFILE="${ROOT_DIR}/justfile"
-for job in publish sync_homebrew verify_distribution; do
- grep -q "^ ${job}:" "$WORKFLOW" || fail "Workflow is missing the ${job} job."
-done
-[[ "$(grep -Fc 'secrets.HOMEBREW_TAP_TOKEN' "$WORKFLOW")" == "1" ]] || fail "Homebrew token must be scoped only to the Homebrew sync checkout."
-grep -Fq 'overwrite_files: true' "$WORKFLOW" || fail "GitHub Release asset upload must be retry-safe."
-grep -Fq 'if: ${{ always() && needs.publish.result == '\''success'\'' }}' "$WORKFLOW" || fail "Distribution verification must run after publish when Homebrew sync fails."
-grep -A4 '^ verify_distribution:' "$WORKFLOW" | grep -q 'runs-on: macos-26' || fail "Distribution verification must run on macOS."
-grep -Fq 'script/verify_release.sh remote "$RELEASE_TAG" "$GITHUB_REPOSITORY" "$EXPECTED_SHA256"' "$WORKFLOW" || fail "Distribution verification must pass the publish SHA."
-
-grep -q '^ release-scripts:$' "$PR_WORKFLOW" || fail "PR Checks must include an independent release-scripts job."
-grep -A4 '^ release-scripts:$' "$PR_WORKFLOW" | grep -q 'runs-on: macos-26' || fail "PR release script tests must run on macOS."
-grep -q '^test-release-scripts:$' "$JUSTFILE" || fail "justfile must expose test-release-scripts."
-grep -Eq '^check: .*test-release-scripts' "$JUSTFILE" || fail "just check must include release script tests."
+
+JOB_NAMES=$(awk '
+ /^jobs:$/ { in_jobs = 1; next }
+ in_jobs && /^[^ ]/ { in_jobs = 0 }
+ in_jobs && /^ [a-zA-Z0-9_]+:$/ {
+ name = $0
+ sub(/^ /, "", name)
+ sub(/:$/, "", name)
+ print name
+ }
+' "$WORKFLOW")
+[[ "$JOB_NAMES" == $'publish\nhomebrew' ]] || fail "Release workflow must contain only publish and homebrew jobs; got: $JOB_NAMES"
+
+assert_contains "$WORKFLOW" 'group: release-${{ github.repository }}'
+assert_contains "$WORKFLOW" 'queue: max'
+assert_contains "$WORKFLOW" "git tag --merged origin/main --list 'v*'"
+assert_contains "$WORKFLOW" 'ruby script/validate_release_order.rb "$RELEASE_TAG"'
+assert_contains "$WORKFLOW" 'cp release-notes.md DerivedData/SparkleFeed/TypeSwitch-macOS-universal.md'
+assert_contains "$WORKFLOW" '--embed-release-notes'
+assert_contains "$WORKFLOW" 'script/verify_release.sh "$RELEASE_TAG" .'
+assert_contains "$WORKFLOW" 'overwrite_files: false'
+assert_not_contains "$WORKFLOW" 'workflow_dispatch'
+assert_not_contains "$WORKFLOW" 'overwrite_files: true'
+assert_not_contains "$WORKFLOW" 'generate_release_notes'
+assert_not_contains "$WORKFLOW" 'verify_distribution'
+assert_not_contains "$WORKFLOW" 'validate_release_version'
+assert_not_contains "$WORKFLOW" 'name: Test release scripts'
+assert_not_contains "$WORKFLOW" 'Validate Homebrew tap access'
+assert_not_contains "$WORKFLOW" 'gh api markdown'
+assert_not_contains "$WORKFLOW" 'release-notes.html'
+[[ "$(grep -Fc 'secrets.HOMEBREW_TAP_TOKEN' "$WORKFLOW")" == "1" ]] || fail "Homebrew token must appear only in the tap checkout."
+[[ ! -e "${ROOT_DIR}/script/validate_release_version.rb" ]] || fail "Obsolete release version helper still exists."
+
+assert_contains "$PR_WORKFLOW" 'release-scripts:'
+assert_contains "$PR_WORKFLOW" 'run: script/test_release_scripts.sh'
+assert_contains "$JUSTFILE" 'test-release-scripts:'
echo "Release script tests passed."
diff --git a/script/update_homebrew_cask.rb b/script/update_homebrew_cask.rb
index 5f36d5f..446fbd2 100755
--- a/script/update_homebrew_cask.rb
+++ b/script/update_homebrew_cask.rb
@@ -2,14 +2,26 @@
path, release_tag, sha256 = ARGV
abort "Usage: script/update_homebrew_cask.rb vX.Y.Z " unless ARGV.length == 3
-abort "Release tag must use strict vX.Y.Z format: #{release_tag}" unless release_tag.match?(/\Av\d+\.\d+\.\d+\z/)
+version_pattern = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\z/
+release_match = /\Av(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\z/.match(release_tag)
+abort "Release tag must use strict vX.Y.Z format: #{release_tag}" unless release_match
abort "SHA-256 must contain exactly 64 hexadecimal characters" unless sha256.match?(/\A[0-9a-fA-F]{64}\z/)
abort "Homebrew cask does not exist: #{path}" unless File.file?(path)
version = release_tag.delete_prefix("v")
+version_parts = release_match.captures.map(&:to_i)
sha256 = sha256.downcase
asset_url = "https://github.com/ygsgdbd/TypeSwitch/releases/download/#{release_tag}/TypeSwitch-macOS-universal.zip"
content = File.read(path)
+current_versions = content.scan(/^ version "([^"]+)"$/).flatten
+abort "Expected exactly one version entry in #{path}, found #{current_versions.length}" unless current_versions.length == 1
+
+current_version = current_versions.first
+current_match = version_pattern.match(current_version)
+abort "Current Homebrew cask version must use strict X.Y.Z format: #{current_version}" unless current_match
+if (version_parts <=> current_match.captures.map(&:to_i)).negative?
+ abort "Refusing to downgrade Homebrew cask from #{current_version} to #{version}."
+end
replacements = {
/^ version ".*"$/ => " version \"#{version}\"",
diff --git a/script/validate_release_order.rb b/script/validate_release_order.rb
new file mode 100644
index 0000000..6ca35a0
--- /dev/null
+++ b/script/validate_release_order.rb
@@ -0,0 +1,25 @@
+#!/usr/bin/env ruby
+
+release_tag = ARGV.first
+abort "Usage: script/validate_release_order.rb vX.Y.Z" unless ARGV.length == 1
+
+tag_pattern = /\Av(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\z/
+release_match = tag_pattern.match(release_tag)
+abort "Release tag must use strict vX.Y.Z format: #{release_tag}" unless release_match
+
+release_version = release_match.captures.map(&:to_i)
+versions = STDIN.each_line.each_with_object([]) do |line, result|
+ match = tag_pattern.match(line.strip)
+ result << [match.captures.map(&:to_i), match[0]] if match
+end
+
+unless versions.any? { |version, tag| version == release_version && tag == release_tag }
+ abort "Release tag #{release_tag} was not found among tags merged into origin/main."
+end
+
+highest_version, highest_tag = versions.max_by(&:first)
+if (release_version <=> highest_version).negative?
+ abort "Release tag #{release_tag} would downgrade the latest version #{highest_tag}."
+end
+
+puts "Release tag #{release_tag} is the highest version on origin/main."
diff --git a/script/verify_release.sh b/script/verify_release.sh
index 8a11643..52425f4 100755
--- a/script/verify_release.sh
+++ b/script/verify_release.sh
@@ -8,61 +8,24 @@ APPCAST_NAME="appcast.xml"
EXPECTED_MINIMUM_SYSTEM_VERSION="14.0"
EXPECTED_FEED_URL="https://github.com/ygsgdbd/TypeSwitch/releases/latest/download/appcast.xml"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
-MODE="${1:-}"
-RELEASE_TAG="${2:-}"
-TEMP_DIRS=()
-VERIFIED_BUILD=""
-VERIFIED_SHA256=""
-NEW_TEMP_DIR=""
+RELEASE_TAG="${1:-}"
+ARTIFACT_DIR="${2:-.}"
+TEMP_DIR=""
cleanup() {
- local path
- for path in "${TEMP_DIRS[@]}"; do
- rm -rf "$path"
- done
-}
-trap cleanup EXIT
-
-new_temp_dir() {
- NEW_TEMP_DIR=$(mktemp -d)
- TEMP_DIRS+=("$NEW_TEMP_DIR")
-}
-
-write_summary() {
- local status="$1"
- local detail="$2"
-
- case "$status" in
- published_and_synced|published_homebrew_pending|publish_failed) ;;
- *) echo "Invalid release summary status: $status" >&2; exit 1 ;;
- esac
-
- if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
- {
- echo "### Release distribution status"
- echo
- echo "- Status: \`${status}\`"
- echo "- Tag: \`${RELEASE_TAG:-unknown}\`"
- echo "- Details: ${detail}"
- echo
- } >> "$GITHUB_STEP_SUMMARY"
+ if [[ -n "$TEMP_DIR" ]]; then
+ rm -rf "$TEMP_DIR"
fi
}
+trap cleanup EXIT
fail() {
- local message="$1"
- local status="${2:-publish_failed}"
- echo "::error title=Release verification failed::${message}" >&2
- write_summary "$status" "$message"
+ echo "::error title=Release verification failed::$1" >&2
exit 1
}
usage() {
- cat >&2 <<'EOF'
-Usage:
- script/verify_release.sh local vX.Y.Z [artifact-directory]
- script/verify_release.sh remote vX.Y.Z
-EOF
+ echo "Usage: script/verify_release.sh vX.Y.Z [artifact-directory]" >&2
exit 2
}
@@ -71,17 +34,7 @@ require_command() {
}
sha256_file() {
- if command -v shasum >/dev/null 2>&1; then
- shasum -a 256 "$1" | awk '{print $1}'
- elif command -v sha256sum >/dev/null 2>&1; then
- sha256sum "$1" | awk '{print $1}'
- else
- fail "Neither shasum nor sha256sum is available."
- fi
-}
-
-file_size() {
- stat -f '%z' "$1"
+ shasum -a 256 "$1" | awk '{print $1}'
}
plist_value() {
@@ -128,148 +81,73 @@ end
signature = enclosure.attributes["sparkle:edSignature"]
abort "Appcast enclosure is missing the Sparkle EdDSA signature" if signature.nil? || signature.empty?
+puts signature
RUBY
}
-verify_local_artifacts() {
- local artifact_dir="$1"
- local zip_path="${artifact_dir}/${ASSET_NAME}"
- local checksums_path="${artifact_dir}/${CHECKSUMS_NAME}"
- local appcast_path="${artifact_dir}/${APPCAST_NAME}"
- local expected_sha actual_sha expected_size expected_url version checksum_count
- local extract_dir app_path info_plist binary_path bundle_version build minimum_system_version feed_url architectures
-
- require_command ditto
- require_command lipo
- require_command plutil
- require_command ruby
-
- for path in "$zip_path" "$checksums_path" "$appcast_path"; do
- [[ -s "$path" ]] || fail "Missing or empty release artifact: ${path}"
- done
-
- checksum_count=$(awk -v asset="$ASSET_NAME" '
- $1 ~ /^[[:xdigit:]]{64}$/ && ($2 == asset || $2 == "*" asset) { count++ }
- END { print count + 0 }
- ' "$checksums_path")
- [[ "$checksum_count" == "1" ]] || fail "${CHECKSUMS_NAME} must contain exactly one checksum for ${ASSET_NAME}; found ${checksum_count}."
-
- expected_sha=$(awk -v asset="$ASSET_NAME" '
- $1 ~ /^[[:xdigit:]]{64}$/ && ($2 == asset || $2 == "*" asset) { print tolower($1) }
- ' "$checksums_path")
- actual_sha=$(sha256_file "$zip_path")
- [[ "$actual_sha" == "$expected_sha" ]] || fail "Checksum mismatch for ${ASSET_NAME}: expected ${expected_sha}, got ${actual_sha}."
-
- new_temp_dir
- extract_dir="$NEW_TEMP_DIR"
- ditto -x -k "$zip_path" "$extract_dir" || fail "Unable to extract ${ASSET_NAME}."
- app_path="${extract_dir}/TypeSwitch.app"
- info_plist="${app_path}/Contents/Info.plist"
- binary_path="${app_path}/Contents/MacOS/TypeSwitch"
- [[ -f "$info_plist" ]] || fail "Release archive is missing TypeSwitch.app/Contents/Info.plist."
- [[ -f "$binary_path" ]] || fail "Release archive is missing the TypeSwitch executable."
-
- version="${RELEASE_TAG#v}"
- bundle_version=$(plist_value CFBundleShortVersionString "$info_plist")
- build=$(plist_value CFBundleVersion "$info_plist")
- minimum_system_version=$(plist_value LSMinimumSystemVersion "$info_plist")
- feed_url=$(plist_value SUFeedURL "$info_plist")
- [[ "$bundle_version" == "$version" ]] || fail "Bundle short version mismatch: expected ${version}, got ${bundle_version}."
- [[ -n "$build" ]] || fail "Bundle build version must not be empty."
- [[ "$minimum_system_version" == "$EXPECTED_MINIMUM_SYSTEM_VERSION" ]] || fail "Minimum system version mismatch: expected ${EXPECTED_MINIMUM_SYSTEM_VERSION}, got ${minimum_system_version}."
- [[ "$feed_url" == "$EXPECTED_FEED_URL" ]] || fail "Sparkle feed URL mismatch: expected ${EXPECTED_FEED_URL}, got ${feed_url}."
-
- architectures=$(lipo -archs "$binary_path") || fail "Unable to inspect TypeSwitch binary architectures."
- [[ " $architectures " == *" arm64 "* && " $architectures " == *" x86_64 "* ]] || fail "Expected universal binary with arm64 and x86_64, got: ${architectures}."
-
- expected_size=$(file_size "$zip_path")
- expected_url="https://github.com/ygsgdbd/TypeSwitch/releases/download/${RELEASE_TAG}/${ASSET_NAME}"
- verify_appcast "$appcast_path" "$version" "$build" "$expected_url" "$expected_size" || fail "${APPCAST_NAME} does not match the app bundle and release artifact."
-
- VERIFIED_BUILD="$build"
- VERIFIED_SHA256="$actual_sha"
- echo "Verified ${RELEASE_TAG}: ${ASSET_NAME} (${actual_sha}), build ${build}, architectures ${architectures}."
-}
-
-verify_release_metadata() {
- local repository="$1"
- local release_json
-
- release_json=$(gh api "repos/${repository}/releases/tags/${RELEASE_TAG}") || fail "Unable to load published GitHub Release ${RELEASE_TAG}."
- if ! printf '%s' "$release_json" | ruby -rjson -e '
- release = JSON.parse(STDIN.read)
- expected_tag = ARGV.fetch(0)
- abort "GitHub Release tag does not match" unless release["tag_name"] == expected_tag
- abort "GitHub Release is still a draft" unless release["draft"] == false
- abort "GitHub Release is marked as a prerelease" unless release["prerelease"] == false
- ' "$RELEASE_TAG"; then
- fail "GitHub Release ${RELEASE_TAG} is not a published production release."
- fi
-}
-
-verify_latest_appcast() {
- local repository="$1"
- local artifact_dir="$2"
- local latest_appcast="${artifact_dir}/latest-appcast.xml"
- local version="${RELEASE_TAG#v}"
- local expected_url="https://github.com/ygsgdbd/TypeSwitch/releases/download/${RELEASE_TAG}/${ASSET_NAME}"
- local expected_size
-
- expected_size=$(file_size "${artifact_dir}/${ASSET_NAME}")
- curl -fsSL -o "$latest_appcast" "https://github.com/${repository}/releases/latest/download/${APPCAST_NAME}" || fail "Unable to download the latest appcast endpoint."
- verify_appcast "$latest_appcast" "$version" "$VERIFIED_BUILD" "$expected_url" "$expected_size" || fail "The latest appcast endpoint does not point to ${RELEASE_TAG}."
-}
-
-verify_homebrew_cask() {
- local artifact_dir="$1"
- local cask_path="${artifact_dir}/typeswitch.rb"
- local original_path="${artifact_dir}/typeswitch.original.rb"
-
- gh api \
- -H "Accept: application/vnd.github.raw+json" \
- "repos/ygsgdbd/homebrew-tap/contents/Casks/typeswitch.rb" \
- > "$cask_path" || fail "Unable to read the published Homebrew cask." published_homebrew_pending
- cp "$cask_path" "$original_path"
-
- if ! ruby "${SCRIPT_DIR}/update_homebrew_cask.rb" "$cask_path" "$RELEASE_TAG" "$VERIFIED_SHA256" >/dev/null; then
- fail "Published Homebrew cask has an invalid structure." published_homebrew_pending
- fi
- cmp -s "$original_path" "$cask_path" || fail "Published Homebrew cask does not match ${RELEASE_TAG} and its Release checksum." published_homebrew_pending
-}
-
-[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "Release tag must use strict vX.Y.Z format; got ${RELEASE_TAG:-}."
-
-case "$MODE" in
- local)
- [[ $# -le 3 ]] || usage
- verify_local_artifacts "${3:-.}"
- ;;
- remote)
- [[ $# -eq 4 ]] || usage
- require_command curl
- require_command gh
- repository="$3"
- [[ "$repository" =~ ^[^/]+/[^/]+$ ]] || fail "Repository must use owner/name format; got ${repository:-}."
- expected_sha256=$(printf '%s' "$4" | tr '[:upper:]' '[:lower:]')
- [[ "$expected_sha256" =~ ^[0-9a-f]{64}$ ]] || fail "Expected SHA-256 must contain exactly 64 hexadecimal characters."
-
- verify_release_metadata "$repository"
- new_temp_dir
- download_dir="$NEW_TEMP_DIR"
- for asset in "$ASSET_NAME" "$CHECKSUMS_NAME" "$APPCAST_NAME"; do
- gh release download "$RELEASE_TAG" --repo "$repository" --dir "$download_dir" --pattern "$asset" || fail "Unable to download ${asset} from ${RELEASE_TAG}."
- done
-
- remote_sha256=$(sha256_file "${download_dir}/${ASSET_NAME}")
- [[ "$remote_sha256" == "$expected_sha256" ]] || fail "Published ${ASSET_NAME} checksum mismatch: expected ${expected_sha256}, got ${remote_sha256}."
-
- verify_local_artifacts "$download_dir"
- gh attestation verify "${download_dir}/${ASSET_NAME}" --repo "$repository" >/dev/null || fail "Artifact attestation verification failed for ${ASSET_NAME}."
- verify_latest_appcast "$repository" "$download_dir"
- verify_homebrew_cask "$download_dir"
- write_summary published_and_synced "GitHub Release, latest appcast, attestation, and Homebrew cask are consistent."
- ;;
- *)
- usage
- ;;
-esac
+[[ $# -ge 1 && $# -le 2 ]] || usage
+[[ "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || fail "Release tag must use vX.Y.Z without leading zeros; got ${RELEASE_TAG:-}."
+
+require_command ditto
+require_command lipo
+require_command plutil
+require_command ruby
+require_command shasum
+
+ZIP_PATH="${ARTIFACT_DIR}/${ASSET_NAME}"
+CHECKSUMS_PATH="${ARTIFACT_DIR}/${CHECKSUMS_NAME}"
+APPCAST_PATH="${ARTIFACT_DIR}/${APPCAST_NAME}"
+for path in "$ZIP_PATH" "$CHECKSUMS_PATH" "$APPCAST_PATH"; do
+ [[ -s "$path" ]] || fail "Missing or empty release artifact: $path"
+done
+
+CHECKSUM_COUNT=$(awk -v asset="$ASSET_NAME" '
+ $1 ~ /^[[:xdigit:]]{64}$/ && ($2 == asset || $2 == "*" asset) { count++ }
+ END { print count + 0 }
+' "$CHECKSUMS_PATH")
+[[ "$CHECKSUM_COUNT" == "1" ]] || fail "${CHECKSUMS_NAME} must contain exactly one checksum for ${ASSET_NAME}; found ${CHECKSUM_COUNT}."
+
+EXPECTED_SHA256=$(awk -v asset="$ASSET_NAME" '
+ $1 ~ /^[[:xdigit:]]{64}$/ && ($2 == asset || $2 == "*" asset) { print tolower($1) }
+' "$CHECKSUMS_PATH")
+ACTUAL_SHA256=$(sha256_file "$ZIP_PATH")
+[[ "$ACTUAL_SHA256" == "$EXPECTED_SHA256" ]] || fail "Checksum mismatch for ${ASSET_NAME}: expected ${EXPECTED_SHA256}, got ${ACTUAL_SHA256}."
+
+TEMP_DIR=$(mktemp -d)
+ditto -x -k "$ZIP_PATH" "$TEMP_DIR" || fail "Unable to extract ${ASSET_NAME}."
+APP_PATH="${TEMP_DIR}/TypeSwitch.app"
+INFO_PLIST="${APP_PATH}/Contents/Info.plist"
+BINARY_PATH="${APP_PATH}/Contents/MacOS/TypeSwitch"
+[[ -f "$INFO_PLIST" ]] || fail "Release archive is missing TypeSwitch.app/Contents/Info.plist."
+[[ -f "$BINARY_PATH" ]] || fail "Release archive is missing the TypeSwitch executable."
+
+VERSION="${RELEASE_TAG#v}"
+BUNDLE_VERSION=$(plist_value CFBundleShortVersionString "$INFO_PLIST")
+BUILD=$(plist_value CFBundleVersion "$INFO_PLIST")
+MINIMUM_SYSTEM_VERSION=$(plist_value LSMinimumSystemVersion "$INFO_PLIST")
+FEED_URL=$(plist_value SUFeedURL "$INFO_PLIST")
+PUBLIC_KEY=$(plist_value SUPublicEDKey "$INFO_PLIST")
+[[ "$BUNDLE_VERSION" == "$VERSION" ]] || fail "Bundle short version mismatch: expected ${VERSION}, got ${BUNDLE_VERSION}."
+[[ -n "$BUILD" ]] || fail "Bundle build version must not be empty."
+[[ "$MINIMUM_SYSTEM_VERSION" == "$EXPECTED_MINIMUM_SYSTEM_VERSION" ]] || fail "Minimum system version mismatch: expected ${EXPECTED_MINIMUM_SYSTEM_VERSION}, got ${MINIMUM_SYSTEM_VERSION}."
+[[ "$FEED_URL" == "$EXPECTED_FEED_URL" ]] || fail "Sparkle feed URL mismatch: expected ${EXPECTED_FEED_URL}, got ${FEED_URL}."
+
+ARCHITECTURES=$(lipo -archs "$BINARY_PATH") || fail "Unable to inspect TypeSwitch binary architectures."
+[[ " $ARCHITECTURES " == *" arm64 "* && " $ARCHITECTURES " == *" x86_64 "* ]] || fail "Expected arm64 and x86_64, got ${ARCHITECTURES}."
+
+EXPECTED_SIZE=$(stat -f '%z' "$ZIP_PATH")
+EXPECTED_URL="https://github.com/ygsgdbd/TypeSwitch/releases/download/${RELEASE_TAG}/${ASSET_NAME}"
+SIGNATURE=$(verify_appcast "$APPCAST_PATH" "$VERSION" "$BUILD" "$EXPECTED_URL" "$EXPECTED_SIZE") || fail "${APPCAST_NAME} does not match the app bundle and release artifact."
+
+SIGN_UPDATE="${SPARKLE_SIGN_UPDATE:-${SCRIPT_DIR}/../sparkle-tools/bin/sign_update}"
+[[ -x "$SIGN_UPDATE" ]] || fail "Sparkle sign_update is unavailable: ${SIGN_UPDATE}"
+LEGACY_VERIFICATION_KEY=$(ruby -rbase64 -e '
+ public_key = Base64.strict_decode64(ARGV.fetch(0))
+ abort "SUPublicEDKey must decode to exactly 32 bytes" unless public_key.bytesize == 32
+ STDOUT.write(Base64.strict_encode64(("\0" * 64) + public_key))
+' "$PUBLIC_KEY") || fail "Unable to prepare the Sparkle public key for signature verification."
+if ! printf '%s' "$LEGACY_VERIFICATION_KEY" | "$SIGN_UPDATE" --ed-key-file - --verify "$ZIP_PATH" "$SIGNATURE" >/dev/null; then
+ fail "Appcast EdDSA signature does not match the built app's SUPublicEDKey."
+fi
+
+echo "Verified ${RELEASE_TAG}: ${ASSET_NAME} (${ACTUAL_SHA256}), build ${BUILD}, architectures ${ARCHITECTURES}."