fix: harden native release asset workflow

This commit is contained in:
tianma-if committed 2026-07-28 22:46:28 +08:00
1 parent fbaa5e7b12
commit 04a3ee477d
8 files changed
+424 -81

No files matched your search

+73 -10
View File
@@ -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:
+73 -14
View File
@@ -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"
+2 -2
View File
@@ -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 可注册;仅检查文件存在或扩展名不视为通过。
+1
View File
@@ -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",
+80
View File
@@ -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 <mobile|desktop> <true|false> <current-tag> [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();
}
@@ -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);
});
});
+69 -55
View File
@@ -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 <mobile|desktop> <base-ref> <head-ref>",
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 <mobile|desktop> <base-ref> <head-ref>",
);
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();
}
+46
View File
@@ -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: [] });
});
});