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}."