From 04a3ee477d95e2f946a0fb7c7f86a3ddfa4dfe7b Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 28 Jul 2026 22:46:28 +0800 Subject: [PATCH] fix: harden native release asset workflow --- .github/workflows/desktop-build.yml | 83 +++++++++++-- .github/workflows/mobile-build.yml | 87 ++++++++++--- AGENTS.md | 4 +- package.json | 1 + scripts/check-native-release-assets.mjs | 80 ++++++++++++ scripts/check-native-release-assets.test.mjs | 80 ++++++++++++ scripts/plan-native-release.mjs | 124 +++++++++++-------- scripts/plan-native-release.test.mjs | 46 +++++++ 8 files changed, 424 insertions(+), 81 deletions(-) create mode 100644 scripts/check-native-release-assets.mjs create mode 100644 scripts/check-native-release-assets.test.mjs create mode 100644 scripts/plan-native-release.test.mjs diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 20cfd421..bac17f99 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -2,6 +2,11 @@ name: Desktop build on: workflow_dispatch: + inputs: + release_tag: + description: "Prepare macOS assets on this existing Draft Release (optional)" + required: false + type: string release: types: [published] @@ -10,10 +15,18 @@ permissions: jobs: release-plan: - if: github.repository == 'tianma-if/edgeever' && github.event_name == 'release' + if: >- + ${{ + github.repository == 'tianma-if/edgeever' && + ( + github.event_name == 'release' || + (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') + ) + }} name: Plan desktop release asset runs-on: ubuntu-latest outputs: + asset_ready: ${{ steps.plan.outputs.asset_ready }} previous_tag: ${{ steps.plan.outputs.previous_tag }} rebuild: ${{ steps.plan.outputs.rebuild }} steps: @@ -22,11 +35,24 @@ jobs: with: fetch-depth: 0 + - name: Validate Draft Release target + if: github.event_name == 'workflow_dispatch' && inputs.release_tag != '' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + expected_version="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")" + test "$RELEASE_TAG" = "v${expected_version}" + test "$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft')" = "true" + target_commitish="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json targetCommitish --jq '.targetCommitish')" + test "$(git rev-parse "${target_commitish}^{commit}")" = "$(git rev-parse "${GITHUB_SHA}^{commit}")" + - name: Compare with previous formal release id: plan env: - CURRENT_TAG: ${{ github.event.release.tag_name }} + CURRENT_TAG: ${{ inputs.release_tag || github.event.release.tag_name }} GH_TOKEN: ${{ github.token }} + HEAD_REF: ${{ github.event_name == 'release' && github.event.release.tag_name || github.sha }} run: | mapfile -t release_tags < <( gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ @@ -42,15 +68,38 @@ jobs: test -n "$previous_tag" git fetch origin "refs/tags/${previous_tag}:refs/tags/${previous_tag}" echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" - node scripts/plan-native-release.mjs desktop "$previous_tag" "$CURRENT_TAG" >> "$GITHUB_OUTPUT" + plan_output="$(node scripts/plan-native-release.mjs desktop "$previous_tag" "$HEAD_REF")" + printf '%s\n' "$plan_output" >> "$GITHUB_OUTPUT" + rebuild="$(printf '%s\n' "$plan_output" | sed -n 's/^rebuild=//p')" + expected_version="$(node -p "JSON.parse(require('fs').readFileSync('apps/desktop/package.json', 'utf8')).version")" + if [[ "$rebuild" = "true" ]]; then + root_version="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")" + test "$expected_version" = "$root_version" + fi + + mapfile -t current_assets < <( + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${CURRENT_TAG}" \ + --jq '.assets[].name' + ) + asset_ready="$( + printf '%s\n' "${current_assets[@]}" | + node scripts/check-native-release-assets.mjs desktop "$rebuild" "$CURRENT_TAG" "$expected_version" + )" + echo "asset_ready=$asset_ready" >> "$GITHUB_OUTPUT" + if [[ "$GITHUB_EVENT_NAME" = "release" && "$asset_ready" != "true" ]]; then + echo "::error::Published Release assets are incomplete or incompatible. Restore the Release to Draft and prepare assets with workflow_dispatch." + exit 1 + fi reuse-release-desktop: needs: release-plan if: >- ${{ github.repository == 'tianma-if/edgeever' && - github.event_name == 'release' && + github.event_name == 'workflow_dispatch' && + inputs.release_tag != '' && needs.release-plan.result == 'success' && + needs.release-plan.outputs.asset_ready == 'false' && needs.release-plan.outputs.rebuild == 'false' }} name: Reuse macOS release assets @@ -58,7 +107,7 @@ jobs: steps: - name: Copy latest compatible macOS assets without renaming env: - CURRENT_TAG: ${{ github.event.release.tag_name }} + CURRENT_TAG: ${{ inputs.release_tag || github.event.release.tag_name }} GH_TOKEN: ${{ github.token }} PREVIOUS_TAG: ${{ needs.release-plan.outputs.previous_tag }} run: | @@ -81,10 +130,12 @@ jobs: always() && github.repository == 'tianma-if/edgeever' && ( - github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_dispatch' && inputs.release_tag == '') || ( - github.event_name == 'release' && + github.event_name == 'workflow_dispatch' && + inputs.release_tag != '' && needs.release-plan.result == 'success' && + needs.release-plan.outputs.asset_ready == 'false' && needs.release-plan.outputs.rebuild == 'true' ) ) @@ -222,15 +273,27 @@ jobs: echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" >> "$GITHUB_ENV" - name: Package desktop installer - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: bun scripts/run-desktop-builder.mjs --publish ${{ github.event_name == 'release' && 'always' || 'never' }} + run: bun scripts/run-desktop-builder.mjs --publish never - name: Verify desktop installer env: EDGE_EVER_VERIFY_TARGET: darwin run: bun scripts/verify-desktop-package.mjs + - name: Upload verified desktop assets to Release + if: github.event_name == 'workflow_dispatch' && inputs.release_tag != '' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + version="$(node -p "JSON.parse(require('fs').readFileSync('apps/desktop/package.json', 'utf8')).version")" + gh release upload "$RELEASE_TAG" \ + "release/desktop/EdgeEver-${version}-mac-arm64.dmg" \ + "release/desktop/EdgeEver-${version}-mac-arm64.dmg.blockmap" \ + "release/desktop/latest-mac.yml" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + - name: Upload desktop installer and metadata uses: actions/upload-artifact@v6 with: diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml index d7206b4e..f7a6f241 100644 --- a/.github/workflows/mobile-build.yml +++ b/.github/workflows/mobile-build.yml @@ -28,10 +28,18 @@ concurrency: jobs: release-plan: - if: github.repository == 'tianma-if/edgeever' && github.event_name == 'release' + if: >- + ${{ + github.repository == 'tianma-if/edgeever' && + ( + github.event_name == 'release' || + (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') + ) + }} name: Plan Android release asset runs-on: ubuntu-latest outputs: + asset_ready: ${{ steps.plan.outputs.asset_ready }} previous_tag: ${{ steps.plan.outputs.previous_tag }} rebuild: ${{ steps.plan.outputs.rebuild }} steps: @@ -40,11 +48,24 @@ jobs: with: fetch-depth: 0 + - name: Validate Draft Release target + if: github.event_name == 'workflow_dispatch' && inputs.release_tag != '' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + expected_version="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")" + test "$RELEASE_TAG" = "v${expected_version}" + test "$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft')" = "true" + target_commitish="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json targetCommitish --jq '.targetCommitish')" + test "$(git rev-parse "${target_commitish}^{commit}")" = "$(git rev-parse "${GITHUB_SHA}^{commit}")" + - name: Compare with previous formal release id: plan env: - CURRENT_TAG: ${{ github.event.release.tag_name }} + CURRENT_TAG: ${{ inputs.release_tag || github.event.release.tag_name }} GH_TOKEN: ${{ github.token }} + HEAD_REF: ${{ github.event_name == 'release' && github.event.release.tag_name || github.sha }} run: | mapfile -t release_tags < <( gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ @@ -60,15 +81,51 @@ jobs: test -n "$previous_tag" git fetch origin "refs/tags/${previous_tag}:refs/tags/${previous_tag}" echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" - node scripts/plan-native-release.mjs mobile "$previous_tag" "$CURRENT_TAG" >> "$GITHUB_OUTPUT" + plan_output="$(node scripts/plan-native-release.mjs mobile "$previous_tag" "$HEAD_REF")" + printf '%s\n' "$plan_output" >> "$GITHUB_OUTPUT" + rebuild="$(printf '%s\n' "$plan_output" | sed -n 's/^rebuild=//p')" + if [[ "$rebuild" = "true" ]]; then + root_version="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")" + mobile_version="$(node -p "JSON.parse(require('fs').readFileSync('apps/mobile/app.json', 'utf8')).expo.version")" + if [[ "$mobile_version" != "$root_version" ]]; then + echo "::error::Mobile rebuild requires apps/mobile/app.json expo.version ($mobile_version) to match package.json version ($root_version)." + exit 1 + fi + + current_version_code="$(node -p "JSON.parse(require('fs').readFileSync('apps/mobile/app.json', 'utf8')).expo.android.versionCode")" + previous_version_code="$( + git show "${previous_tag}:apps/mobile/app.json" | + node -e "const fs = require('fs'); console.log(JSON.parse(fs.readFileSync(0, 'utf8')).expo.android.versionCode)" + )" + if (( current_version_code <= previous_version_code )); then + echo "::error::Mobile rebuild requires android.versionCode ($current_version_code) to be greater than the previous release ($previous_version_code)." + exit 1 + fi + fi + + mapfile -t current_assets < <( + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${CURRENT_TAG}" \ + --jq '.assets[].name' + ) + asset_ready="$( + printf '%s\n' "${current_assets[@]}" | + node scripts/check-native-release-assets.mjs mobile "$rebuild" "$CURRENT_TAG" + )" + echo "asset_ready=$asset_ready" >> "$GITHUB_OUTPUT" + if [[ "$GITHUB_EVENT_NAME" = "release" && "$asset_ready" != "true" ]]; then + echo "::error::Published Release assets are incomplete or incompatible. Restore the Release to Draft and prepare assets with workflow_dispatch." + exit 1 + fi reuse-release-apk: needs: release-plan if: >- ${{ github.repository == 'tianma-if/edgeever' && - github.event_name == 'release' && + github.event_name == 'workflow_dispatch' && + inputs.release_tag != '' && needs.release-plan.result == 'success' && + needs.release-plan.outputs.asset_ready == 'false' && needs.release-plan.outputs.rebuild == 'false' }} name: Reuse Android release APK @@ -76,7 +133,7 @@ jobs: steps: - name: Copy latest compatible APK without renaming env: - CURRENT_TAG: ${{ github.event.release.tag_name }} + CURRENT_TAG: ${{ inputs.release_tag || github.event.release.tag_name }} GH_TOKEN: ${{ github.token }} PREVIOUS_TAG: ${{ needs.release-plan.outputs.previous_tag }} run: | @@ -97,11 +154,13 @@ jobs: always() && github.repository == 'tianma-if/edgeever' && ( - github.event_name == 'workflow_dispatch' || - github.ref == 'refs/heads/main' || + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_dispatch' && inputs.release_tag == '') || ( - github.event_name == 'release' && + github.event_name == 'workflow_dispatch' && + inputs.release_tag != '' && needs.release-plan.result == 'success' && + needs.release-plan.outputs.asset_ready == 'false' && needs.release-plan.outputs.rebuild == 'true' ) ) @@ -139,7 +198,7 @@ jobs: run: bun run build:android:fast - name: Restore Android upload keystore - if: github.event_name == 'workflow_dispatch' || github.event_name == 'release' + if: github.event_name == 'workflow_dispatch' env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: | @@ -157,7 +216,7 @@ jobs: run: bun run build:android:play - name: Build signed release APK for GitHub Release - if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') + if: github.event_name == 'workflow_dispatch' && inputs.release_tag != '' env: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/edgeever-upload.p12 @@ -173,7 +232,7 @@ jobs: working-directory: apps/mobile/android - name: Verify release APK signature - if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') + if: github.event_name == 'workflow_dispatch' && inputs.release_tag != '' run: | "$ANDROID_SDK_ROOT/build-tools/36.0.0/apksigner" verify --verbose app/build/outputs/apk/release/app-release.apk working-directory: apps/mobile/android @@ -202,10 +261,10 @@ jobs: compression-level: 0 - name: Upload release APK to GitHub Release - if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') + if: github.event_name == 'workflow_dispatch' && inputs.release_tag != '' env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} + RELEASE_TAG: ${{ inputs.release_tag }} run: | release_asset="$RUNNER_TEMP/edgeever-android-${RELEASE_TAG}-arm64-v8a.apk" cp apps/mobile/android/app/build/outputs/apk/release/app-release.apk "$release_asset" @@ -220,5 +279,5 @@ jobs: if-no-files-found: error - name: Remove temporary upload keystore - if: always() && (github.event_name == 'workflow_dispatch' || github.event_name == 'release') + if: always() && github.event_name == 'workflow_dispatch' run: rm -f "$RUNNER_TEMP/edgeever-upload.p12" diff --git a/AGENTS.md b/AGENTS.md index ede335dd..087ca5ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,8 +13,8 @@ 1. **版本号与基线**:使用 `vX.Y.Z` 格式(非 Draft/Prerelease)。递增根目录 `package.json`;若含移动端修改,同步更新 `apps/mobile/app.json` 的 `expo.version` 并递增 `android.versionCode`。上一个实际 Release 为审计基线。 2. **跨平台 Release 资产**:每个正式 Release 页面必须同时包含 macOS arm64 DMG 和 Android arm64 APK。若本次未修改对应原生运行时代码、依赖、配置或构建工具,直接复用上一个正式 Release 中已验证的原始资产,保留原文件名与校验和,禁止仅为匹配新版本号而重命名。 3. **验证命令**:必须通过 `bun run typecheck`、`bun run typecheck:mobile` 和 `bun run build:web`。 -4. **原生资产构建与复用**:仅当变更影响移动端运行时代码、共享依赖、原生配置或 Android 构建工具时,执行 `bun run build:android:apk:local` 构建生产签名 APK(签名配置必须位于仓库外 `~/.config/edgeever/android/signing.env`);仅当变更影响 Electron、Rust sidecar、原生依赖、打包配置或桌面构建工具时重新构建 macOS DMG。无对应原生变更时,由 Release 工作流复用最近兼容资产。 -5. **禁止公开未验收资产**:Release 准备期间必须保持 Draft;只有最终资产完成本节全部验收后才能公开为正式 Release。禁止把关闭签名或公证生成的本地 DMG 上传到公开 Release;此类产物只能用于开发排障。若 CI 只能通过 `published` 事件触发,应在触发后立即转回 Draft,并在最终公开前确认没有重复工作流仍可能覆盖已验收资产。 +4. **原生资产构建与复用**:由 `scripts/plan-native-release.mjs` 决定重建或复用;桌面资产包含 `apps/web`。修改判定规则时同步更新测试。移动端重建使用 `bun run build:android:apk:local`,签名配置保存在仓库外。 +5. **Draft 内准备资产**:通过带 `release_tag` 的 `workflow_dispatch` 在 Draft 中准备并验证资产;`published` 事件只审计,禁止重新构建或上传。 6. **macOS 最终资产验收**:验收对象必须是从 GitHub Release 页面重新下载的最终 DMG,禁止用本地构建目录中的产物代替。必须核对 Release digest/本地 SHA-256,并依次通过 `hdiutil verify`、挂载、`codesign --verify --deep --strict`、`spctl --assess --type execute`(结果必须为 `accepted` 且来源为 `Notarized Developer ID`)和 `xcrun stapler validate`。随后必须写入模拟浏览器下载的 `com.apple.quarantine` 属性,将 App 复制到临时目录并实际启动;任何一步失败都禁止发布,也禁止指导用户绕过 Gatekeeper。 7. **桌面端真实首启冒烟测试**:凡涉及 Electron、preload、认证、实例地址、首次引导或本地配置,必须对最终打包 App 使用全新的临时 `--user-data-dir` 实际启动,不能复用开发者机器上的历史配置。测试至少覆盖 preload 桥接可用、未配置实例时实例地址为空、首次打开显示实例地址输入界面,以及保存后能进入预期流程。类型检查、Web 构建、ASAR 文件存在性检查和已有用户配置下的启动均不能替代该测试。 8. **桌面包结构门禁**:桌面变更除通用验证命令外,必须运行桌面测试及 `EDGE_EVER_VERIFY_TARGET=darwin bun run verify:desktop-package`。验证脚本必须实际加载打包后的 preload 入口并确认桥接 API 可注册;仅检查文件存在或扩展名不视为通过。 diff --git a/package.json b/package.json index df5b0d44..4be3d256 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "build:desktop": "EDGE_EVER_DESKTOP_BUILD=1 bun run build:web && bun run build:desktop:sidecar && bun run --cwd apps/desktop dist", "benchmark:desktop:sidecar": "bun scripts/benchmark-desktop-sidecar.mjs", "verify:web-performance": "bun scripts/verify-web-performance.mjs", + "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs", "test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs", "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar", "verify:desktop-package": "bun scripts/verify-desktop-package.mjs", diff --git a/scripts/check-native-release-assets.mjs b/scripts/check-native-release-assets.mjs new file mode 100644 index 00000000..91cebd17 --- /dev/null +++ b/scripts/check-native-release-assets.mjs @@ -0,0 +1,80 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const nativeReleaseAssetsReady = ({ + platform, + rebuild, + currentTag, + desktopVersion, + assetNames, +}) => { + if (platform === "mobile") { + const apkNames = assetNames.filter((name) => + /^edgeever-android-v.*-arm64-v8a\.apk$/.test(name), + ); + if (apkNames.length !== 1) return false; + return ( + !rebuild || + apkNames[0] === `edgeever-android-${currentTag}-arm64-v8a.apk` + ); + } + + if (platform === "desktop") { + const dmgNames = assetNames.filter((name) => + /^EdgeEver-.*-mac-arm64\.dmg$/.test(name), + ); + const blockmapNames = assetNames.filter((name) => + /^EdgeEver-.*-mac-arm64\.dmg\.blockmap$/.test(name), + ); + if ( + dmgNames.length !== 1 || + blockmapNames.length !== 1 || + assetNames.filter((name) => name === "latest-mac.yml").length !== 1 || + blockmapNames[0] !== `${dmgNames[0]}.blockmap` + ) { + return false; + } + return ( + !rebuild || + dmgNames[0] === `EdgeEver-${desktopVersion}-mac-arm64.dmg` + ); + } + + throw new Error(`Unsupported native release platform: ${platform}`); +}; + +const run = () => { + const [platform, rebuildValue, currentTag, desktopVersion = ""] = + process.argv.slice(2); + if ( + !["mobile", "desktop"].includes(platform) || + !["true", "false"].includes(rebuildValue) || + !currentTag + ) { + console.error( + "Usage: node scripts/check-native-release-assets.mjs [desktop-version]", + ); + process.exit(1); + } + + const assetNames = readFileSync(0, "utf8").split("\n").filter(Boolean); + process.stdout.write( + String( + nativeReleaseAssetsReady({ + platform, + rebuild: rebuildValue === "true", + currentTag, + desktopVersion, + assetNames, + }), + ), + ); +}; + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + run(); +} diff --git a/scripts/check-native-release-assets.test.mjs b/scripts/check-native-release-assets.test.mjs new file mode 100644 index 00000000..a89acb8b --- /dev/null +++ b/scripts/check-native-release-assets.test.mjs @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { nativeReleaseAssetsReady } from "./check-native-release-assets.mjs"; + +describe("native release asset readiness", () => { + test("accepts one reused Android APK but requires the current name after a rebuild", () => { + const reused = ["edgeever-android-v1.6.30-arm64-v8a.apk"]; + expect( + nativeReleaseAssetsReady({ + platform: "mobile", + rebuild: false, + currentTag: "v1.6.35", + assetNames: reused, + }), + ).toBe(true); + expect( + nativeReleaseAssetsReady({ + platform: "mobile", + rebuild: true, + currentTag: "v1.6.35", + assetNames: reused, + }), + ).toBe(false); + }); + + test("rejects duplicate Android APKs", () => { + expect( + nativeReleaseAssetsReady({ + platform: "mobile", + rebuild: false, + currentTag: "v1.6.35", + assetNames: [ + "edgeever-android-v1.6.30-arm64-v8a.apk", + "edgeever-android-v1.6.35-arm64-v8a.apk", + ], + }), + ).toBe(false); + }); + + test("accepts a matching reused desktop asset set", () => { + expect( + nativeReleaseAssetsReady({ + platform: "desktop", + rebuild: false, + currentTag: "v1.6.35", + desktopVersion: "1.6.35", + assetNames: [ + "EdgeEver-1.6.33-mac-arm64.dmg", + "EdgeEver-1.6.33-mac-arm64.dmg.blockmap", + "latest-mac.yml", + ], + }), + ).toBe(true); + }); + + test("requires current, complete desktop assets after a rebuild", () => { + const current = [ + "EdgeEver-1.6.35-mac-arm64.dmg", + "EdgeEver-1.6.35-mac-arm64.dmg.blockmap", + "latest-mac.yml", + ]; + expect( + nativeReleaseAssetsReady({ + platform: "desktop", + rebuild: true, + currentTag: "v1.6.35", + desktopVersion: "1.6.35", + assetNames: current, + }), + ).toBe(true); + expect( + nativeReleaseAssetsReady({ + platform: "desktop", + rebuild: true, + currentTag: "v1.6.35", + desktopVersion: "1.6.35", + assetNames: current.slice(0, 2), + }), + ).toBe(false); + }); +}); diff --git a/scripts/plan-native-release.mjs b/scripts/plan-native-release.mjs index 0bec051a..63138fd8 100644 --- a/scripts/plan-native-release.mjs +++ b/scripts/plan-native-release.mjs @@ -1,68 +1,82 @@ import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; -const [platform, baseRef, headRef] = process.argv.slice(2); +export const planNativeRelease = (platform, changedFiles) => { + if (!["mobile", "desktop"].includes(platform)) { + throw new Error(`Unsupported native release platform: ${platform}`); + } -if (!["mobile", "desktop"].includes(platform) || !baseRef || !headRef) { - console.error( - "Usage: node scripts/plan-native-release.mjs ", + const runtimeChangedFiles = changedFiles.filter( + (file) => !file.endsWith(".md"), ); - process.exit(1); -} -const git = (...args) => - execFileSync("git", args, { - encoding: "utf8", - }).trim(); + const relevantPrefixes = + platform === "mobile" + ? ["apps/mobile/", "packages/client/", "packages/shared/"] + : [ + "apps/desktop/", + "apps/web/", + "crates/desktop-sidecar/", + "packages/client/", + "packages/shared/", + ]; -const changedFiles = git("diff", "--name-only", `${baseRef}...${headRef}`) - .split("\n") - .filter(Boolean); -const runtimeChangedFiles = changedFiles.filter( - (file) => !file.endsWith(".md"), -); + const relevantFiles = + platform === "mobile" + ? new Set(["bun.lock", "scripts/build-android-local.sh"]) + : new Set([ + "bun.lock", + "scripts/run-desktop-builder.mjs", + "scripts/verify-desktop-package.mjs", + ]); -const packageJsonChangedBeyondVersion = () => { - if (!changedFiles.includes("package.json")) return false; + const relevantChanges = runtimeChangedFiles.filter( + (file) => + relevantPrefixes.some((prefix) => file.startsWith(prefix)) || + relevantFiles.has(file), + ); - const readPackage = (ref) => { - const packageJson = JSON.parse(git("show", `${ref}:package.json`)); - delete packageJson.version; - return packageJson; + return { + rebuild: relevantChanges.length > 0, + relevantChanges, }; +}; - return ( - JSON.stringify(readPackage(baseRef)) !== - JSON.stringify(readPackage(headRef)) +const run = () => { + const [platform, baseRef, headRef] = process.argv.slice(2); + + if (!["mobile", "desktop"].includes(platform) || !baseRef || !headRef) { + console.error( + "Usage: node scripts/plan-native-release.mjs ", + ); + process.exit(1); + } + + const git = (...args) => + execFileSync("git", args, { + encoding: "utf8", + }).trim(); + + const changedFiles = git("diff", "--name-only", `${baseRef}...${headRef}`) + .split("\n") + .filter(Boolean); + const { rebuild, relevantChanges } = planNativeRelease( + platform, + changedFiles, + ); + + process.stdout.write(`rebuild=${rebuild}\n`); + process.stderr.write( + `${platform} release plan: ${rebuild ? "rebuild" : "reuse"}${ + relevantChanges.length > 0 ? ` (${relevantChanges.join(", ")})` : "" + }\n`, ); }; -const relevantPrefixes = - platform === "mobile" - ? ["apps/mobile/", "packages/client/", "packages/shared/"] - : ["apps/desktop/", "crates/desktop-sidecar/"]; - -const relevantFiles = - platform === "mobile" - ? new Set(["bun.lock", "scripts/build-android-local.sh"]) - : new Set([ - "scripts/run-desktop-builder.mjs", - "scripts/verify-desktop-package.mjs", - ]); - -const relevantChanges = runtimeChangedFiles.filter( - (file) => - relevantPrefixes.some((prefix) => file.startsWith(prefix)) || - relevantFiles.has(file) || - (platform === "mobile" && - file === "package.json" && - packageJsonChangedBeyondVersion()), -); - -const rebuild = relevantChanges.length > 0; - -process.stdout.write(`rebuild=${rebuild}\n`); -process.stderr.write( - `${platform} release plan: ${rebuild ? "rebuild" : "reuse"}${ - relevantChanges.length > 0 ? ` (${relevantChanges.join(", ")})` : "" - }\n`, -); +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + run(); +} diff --git a/scripts/plan-native-release.test.mjs b/scripts/plan-native-release.test.mjs new file mode 100644 index 00000000..58aee605 --- /dev/null +++ b/scripts/plan-native-release.test.mjs @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { planNativeRelease } from "./plan-native-release.mjs"; + +describe("native release planning", () => { + test("does not rebuild Android for root scripts, versions, or documentation", () => { + expect( + planNativeRelease("mobile", [ + "package.json", + "AGENTS.md", + "apps/web/src/app/App.tsx", + "apps/desktop/package.json", + ]), + ).toEqual({ rebuild: false, relevantChanges: [] }); + }); + + test("rebuilds Android for mobile, shared runtime, dependency, or build changes", () => { + const changedFiles = [ + "apps/mobile/src/screens/LoginScreen.tsx", + "packages/shared/src/index.ts", + "bun.lock", + "scripts/build-android-local.sh", + ]; + expect(planNativeRelease("mobile", changedFiles)).toEqual({ + rebuild: true, + relevantChanges: changedFiles, + }); + }); + + test("rebuilds desktop for its embedded Web renderer and shared runtime", () => { + const changedFiles = [ + "apps/web/src/app/App.tsx", + "apps/desktop/src/main/index.mjs", + "packages/shared/src/index.ts", + ]; + expect(planNativeRelease("desktop", changedFiles)).toEqual({ + rebuild: true, + relevantChanges: changedFiles, + }); + }); + + test("does not rebuild desktop for release notes or a root version bump alone", () => { + expect( + planNativeRelease("desktop", ["package.json", "AGENTS.md"]), + ).toEqual({ rebuild: false, relevantChanges: [] }); + }); +});