diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ada82c9..ddff605 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: - run: pnpm format:check - run: pnpm test - run: pnpm test:release + - run: pnpm test:package - run: pnpm test:store - name: Check project versions shell: bash diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..dc022ce --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,62 @@ +name: Package + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: package-${{ github.ref }} + cancel-in-progress: false + +jobs: + checks: + name: Package quality gate + uses: ./.github/workflows/ci.yml + with: + ref: ${{ github.ref }} + + prepare: + name: Resolve native platform matrix + needs: checks + runs-on: ubuntu-22.04 + outputs: + matrix: ${{ steps.platforms.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - id: platforms + shell: bash + run: node scripts/package.mjs matrix >> "$GITHUB_OUTPUT" + + build: + name: Package (${{ matrix.platform }}) + needs: prepare + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }} + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + with: + targets: ${{ matrix.rustTargets }} + - run: pnpm install --frozen-lockfile + - name: Build unsigned test installer + run: node scripts/package.mjs build ${{ matrix.platform }} + env: + CI: "true" + - name: Collect installer and SHA-256 + run: node scripts/package.mjs collect ${{ matrix.platform }} + env: + PACKAGE_OUTPUT_DIR: package-assets/${{ matrix.platform }} + - uses: actions/upload-artifact@v4 + with: + name: package-${{ matrix.platform }} + path: package-assets/${{ matrix.platform }}/ + if-no-files-found: error + retention-days: 14 diff --git a/Justfile b/Justfile index e6c45c8..dd1ed29 100644 --- a/Justfile +++ b/Justfile @@ -23,6 +23,7 @@ check: test: pnpm test pnpm test:release + pnpm test:package pnpm test:store cargo test --locked --manifest-path src-tauri/Cargo.toml @@ -35,6 +36,22 @@ format: build: pnpm build +# 在当前系统生成测试安装包:macOS 为 Intel/Apple Silicon 通用 DMG。 +package-local: + node scripts/package.mjs package-local + +# 在对应原生系统打包 macos-universal、windows-x64、windows-arm64、linux-x64 或 linux-arm64。 +package platform: + node scripts/package.mjs package {{quote(platform)}} + +# 从已推送、无本地修改的当前分支触发五平台云端测试打包;不会创建 Release。 +package-all branch="": + node scripts/package.mjs dispatch {{quote(branch)}} + +# 下载指定 Actions 运行的五平台测试包并校验 SHA-256。 +package-download run_id: + node scripts/package.mjs download {{quote(run_id)}} + # 从生成的图标母版导出平台图标。 icons: pnpm tauri icon assets/power-switch-master.png --output src-tauri/icons diff --git a/docs/releasing.md b/docs/releasing.md index 0fd349b..22cb996 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,5 +1,22 @@ # 发布说明 +## 测试安装包(不发布版本) + +`just package-local` 在当前系统打包并收集测试安装包;macOS 始终生成 Intel 与 Apple Silicon 通用 DMG。也可用 `just package macos-universal`、`just package windows-x64`、`just package windows-arm64`、`just package linux-x64` 或 `just package linux-arm64` 明确指定平台。Windows 与 Linux 命令须在对应架构的原生系统执行。命令会检查 Rust 目标库、使用锁定依赖,并把安装包和 `SHA256SUMS` 放在 `artifacts/packages/v<版本>-<提交前 12 位>[-dirty]/<平台>/`。 + +本地需要 Node.js、pnpm、just 和对应系统的 Tauri 原生依赖。macOS 通用包还需要同时安装 `aarch64-apple-darwin` 与 `x86_64-apple-darwin` Rust 目标;缺少时可使用 rustup 管理工具链并执行 `rustup target add aarch64-apple-darwin x86_64-apple-darwin`。 + +从 Mac 触发全部五个平台的原生构建: + +```sh +just package-all +# 或在已检出该分支时显式指定:just package-all my-branch +gh run list --repo winwin-inc/power-switch --workflow package.yml +just package-download <成功运行的 ID> +``` + +云端命令要求当前分支工作区干净,且 GitHub 上该分支的提交与本地 `HEAD` 相同。`Package` 工作流只上传保留 14 天的 Actions 工件;下载命令把它们放在 `artifacts/packages/cloud/<运行 ID>/` 并逐平台验证 SHA-256。测试包关闭更新器产物与签名,不创建 GitHub Release,也不能充当正式更新资产。正式版本仍使用下文的标签发布流程;Microsoft Store MSIX 使用独立工作流。 + ## 分支与版本策略 本项目使用 GitHub Actions 执行持续集成与桌面应用发布,采用以下策略: diff --git a/package.json b/package.json index 14a8f2c..5fcc531 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:release": "node --test scripts/release.test.mjs", + "test:package": "node --test scripts/package.test.mjs", "test:store": "node --test scripts/store-msix.test.mjs", "release:check": "node scripts/release.mjs check", "format": "prettier --write src scripts docs *.json *.ts *.html *.md .github", diff --git a/scripts/package.mjs b/scripts/package.mjs new file mode 100644 index 0000000..09e79fb --- /dev/null +++ b/scripts/package.mjs @@ -0,0 +1,404 @@ +import { execFileSync } from "node:child_process"; +import { createReadStream, existsSync } from "node:fs"; +import { + copyFile, + lstat, + mkdir, + readdir, + readFile, + writeFile, +} from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { arch, platform as operatingSystem } from "node:process"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { checkVersion, targets, versionFromTag } from "./release.mjs"; + +const repository = "winwin-inc/power-switch"; +const root = resolve(fileURLToPath(new URL("..", import.meta.url))); + +/** 将唯一的平台配置转换为 GitHub Actions 的原生运行器矩阵。 */ +export function packageMatrix() { + return { + include: Object.entries(targets).map(([name, target]) => ({ + platform: name, + os: target.runner, + rustTargets: target.rustTargets, + })), + }; +} + +/** 按当前系统和 CPU 架构选择可在本机打包的平台。 */ +export function localPlatform(host = operatingSystem, cpu = arch) { + const match = Object.entries(targets).find( + ([, target]) => + target.host === host && (!target.arch || target.arch === cpu), + ); + if (!match) throw new Error(`Unsupported packaging host: ${host}/${cpu}`); + return match[0]; +} + +/** 拒绝在不匹配的主机上运行原生安装器构建。 */ +export function assertHost(name, host = operatingSystem, cpu = arch) { + const target = targets[name]; + if (!target) throw new Error(`Unknown package platform: ${name}`); + if (target.host !== host || (target.arch && target.arch !== cpu)) { + throw new Error( + `${name} requires ${target.host}/${target.arch ?? "any"}; found ${host}/${cpu}`, + ); + } + return target; +} + +/** 构造无更新器签名、固定目标架构的测试安装包命令。 */ +export function buildArguments(name) { + const target = targets[name]; + if (!target) throw new Error(`Unknown package platform: ${name}`); + return [ + "tauri", + "build", + "--ci", + "--target", + target.triple, + "--bundles", + target.bundles, + "--config", + JSON.stringify({ bundle: { createUpdaterArtifacts: false } }), + "--no-sign", + "--", + "--locked", + ]; +} + +/** 检查项目依赖和 Rust 目标,缺项时在耗时构建前失败。 */ +export function assertBuildPrerequisites(name, options = {}) { + const target = assertHost(name, options.host, options.cpu); + const sysroot = execFileSync("rustc", ["--print", "sysroot"], { + cwd: root, + encoding: "utf8", + }).trim(); + const missing = target.rustTargets + .split(",") + .filter( + (rustTarget) => + !existsSync(join(sysroot, "lib", "rustlib", rustTarget, "lib")), + ); + if (missing.length) { + throw new Error( + `Missing Rust target(s): ${missing.join(", ")}. Install a rustup-managed toolchain, then run rustup target add ${missing.join(" ")}`, + ); + } +} + +/** 在当前系统执行测试安装器构建,不读取更新签名私钥。 */ +export async function buildPackage(name) { + assertBuildPrerequisites(name); + const manifest = JSON.parse( + await readFile(join(root, "package.json"), "utf8"), + ); + await checkVersion(root, `v${manifest.version}`); + await lstat(join(root, "node_modules", ".bin", "tauri")).catch(() => { + throw new Error( + "Missing frontend dependencies. Run pnpm install --frozen-lockfile.", + ); + }); + execFileSync("pnpm", buildArguments(name), { cwd: root, stdio: "inherit" }); +} + +/** 安装锁定依赖后在本机完成构建和收集。 */ +export async function packageHere(name) { + assertBuildPrerequisites(name); + execFileSync("pnpm", ["install", "--frozen-lockfile"], { + cwd: root, + stdio: "inherit", + }); + await buildPackage(name); + return collectPackage(name); +} + +/** 只收集一个非空普通安装包,拒绝陈旧的重复产物或符号链接。 */ +async function findInstaller(directory, extension) { + const matches = []; + /** 在打包目录内递归查找扩展名匹配的普通文件。 */ + async function visit(current) { + for (const entry of await readdir(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) await visit(path); + else if (entry.name.endsWith(`.${extension}`)) { + const stat = await lstat(path); + if (!stat.isFile() || stat.size === 0) + throw new Error(`Invalid package file: ${path}`); + matches.push(path); + } + } + } + await visit(directory); + if (matches.length !== 1) + throw new Error( + `Expected one .${extension} in ${directory}; found ${matches.length}`, + ); + return matches[0]; +} + +/** 流式计算文件校验值,避免把大型安装包一次读入内存。 */ +async function fileHash(path) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +} + +/** 用版本、提交和脏工作区标识给测试包建立独立目录。 */ +export async function packageIdentity(projectRoot = root) { + const manifest = JSON.parse( + await readFile(join(projectRoot, "package.json"), "utf8"), + ); + const version = versionFromTag(`v${manifest.version}`); + const sha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: projectRoot, + encoding: "utf8", + }).trim(); + const dirty = execFileSync( + "git", + ["status", "--porcelain", "--untracked-files=normal"], + { + cwd: projectRoot, + encoding: "utf8", + }, + ).trim(); + return `v${version}-${sha.slice(0, 12)}${dirty ? "-dirty" : ""}`; +} + +/** 复制当前平台的测试安装包,并写入可独立验证的 SHA-256 清单。 */ +export async function collectPackage(name, projectRoot = root) { + const target = targets[name]; + if (!target) throw new Error(`Unknown package platform: ${name}`); + const identity = await packageIdentity(projectRoot); + const base = join( + projectRoot, + "src-tauri", + "target", + target.triple, + "release", + "bundle", + ); + if (name === "macos-universal") { + execFileSync( + "lipo", + [ + join( + base, + "macos", + "power-switch.app", + "Contents", + "MacOS", + "power-switch", + ), + "-verify_arch", + "x86_64", + "arm64", + ], + { stdio: "inherit" }, + ); + } + const sources = []; + for (const extension of target.installers) + sources.push([extension, await findInstaller(base, extension)]); + const directory = process.env.PACKAGE_OUTPUT_DIR + ? resolve(projectRoot, process.env.PACKAGE_OUTPUT_DIR) + : join(projectRoot, "artifacts", "packages", identity, name); + await mkdir(directory, { recursive: true }); + const lines = []; + for (const [extension, source] of sources) { + const filename = `power-switch-${identity}-${name}.${extension}`; + const destination = join(directory, filename); + await copyFile(source, destination); + lines.push(`${await fileHash(destination)} ${filename}`); + } + await writeFile(join(directory, "SHA256SUMS"), `${lines.join("\n")}\n`); + await verifyPackage(directory, name); + return directory; +} + +/** 校验下载的工件只含预期安装包和正确的校验值。 */ +export async function verifyPackage(directory, name) { + const target = targets[name]; + if (!target) throw new Error(`Unknown package platform: ${name}`); + const lines = (await readFile(join(directory, "SHA256SUMS"), "utf8")) + .trim() + .split("\n"); + if (lines.length !== target.installers.length) + throw new Error(`Incomplete checksums for ${name}`); + const extensions = new Set(); + for (const line of lines) { + const match = + /^([a-f0-9]{64}) (power-switch-[A-Za-z0-9.+-]+\.([A-Za-z0-9]+))$/.exec( + line, + ); + if ( + !match || + !target.installers.includes(match[3]) || + !match[2].endsWith(`-${name}.${match[3]}`) || + extensions.has(match[3]) + ) + throw new Error(`Invalid checksum entry for ${name}: ${line}`); + extensions.add(match[3]); + const path = join(directory, match[2]); + const stat = await lstat(path); + if ( + !stat.isFile() || + stat.size === 0 || + (await fileHash(path)) !== match[1] + ) + throw new Error(`Package checksum failed: ${path}`); + } + const expected = [ + "SHA256SUMS", + ...lines.map((line) => line.slice(66)), + ].sort(); + const actual = (await readdir(directory)).sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) + throw new Error(`Unexpected package files in ${directory}`); +} + +/** 只从已推送且与当前 HEAD 一致的干净分支发起云端打包。 */ +export function dispatchPackage(branch) { + const current = execFileSync("git", ["branch", "--show-current"], { + cwd: root, + encoding: "utf8", + }).trim(); + const selected = branch || current; + if (!selected || selected !== current) + throw new Error( + "Check out the branch to package before running package-all.", + ); + execFileSync("git", ["check-ref-format", "--branch", selected], { + cwd: root, + stdio: "ignore", + }); + const dirty = execFileSync( + "git", + ["status", "--porcelain", "--untracked-files=normal"], + { + cwd: root, + encoding: "utf8", + }, + ).trim(); + if (dirty) + throw new Error("Commit or remove local changes before cloud packaging."); + const localSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + }).trim(); + const remoteSha = execFileSync( + "gh", + [ + "api", + `repos/${repository}/git/ref/heads/${selected}`, + "--jq", + ".object.sha", + ], + { + cwd: root, + encoding: "utf8", + }, + ).trim(); + if (remoteSha !== localSha) + throw new Error( + `Push ${selected} before cloud packaging (local ${localSha}, remote ${remoteSha}).`, + ); + execFileSync( + "gh", + ["workflow", "run", "package.yml", "--repo", repository, "--ref", selected], + { + cwd: root, + stdio: "inherit", + }, + ); + console.log( + `Packaging ${selected}@${localSha}. Find the run with: gh run list --repo ${repository} --workflow package.yml`, + ); +} + +/** 下载成功的云端打包运行并逐平台核验安装包。 */ +export async function downloadPackage(runId) { + if (!/^[1-9]\d*$/.test(runId ?? "")) + throw new Error("Expected a numeric GitHub Actions run ID."); + const run = JSON.parse( + execFileSync( + "gh", + [ + "run", + "view", + runId, + "--repo", + repository, + "--json", + "workflowName,status,conclusion", + ], + { + cwd: root, + encoding: "utf8", + }, + ), + ); + if ( + run.workflowName !== "Package" || + run.status !== "completed" || + run.conclusion !== "success" + ) + throw new Error(`Run ${runId} is not a successful Package workflow.`); + const directory = join(root, "artifacts", "packages", "cloud", runId); + await mkdir(directory, { recursive: true }); + execFileSync( + "gh", + [ + "run", + "download", + runId, + "--repo", + repository, + "--pattern", + "package-*", + "--dir", + directory, + ], + { + cwd: root, + stdio: "inherit", + }, + ); + for (const name of Object.keys(targets)) + await verifyPackage(join(directory, `package-${name}`), name); + return directory; +} + +/** 提供矩阵、构建、收集和 GitHub CLI 操作的统一命令行入口。 */ +async function main() { + const [command, value] = process.argv.slice(2); + if (command === "matrix") + console.log(`matrix=${JSON.stringify(packageMatrix())}`); + else if (command === "local-platform") console.log(localPlatform()); + else if (command === "package-local") + console.log(await packageHere(localPlatform())); + else if (command === "package" && value) + console.log(await packageHere(value)); + else if (command === "build" && value) await buildPackage(value); + else if (command === "collect" && value) + console.log(await collectPackage(value)); + else if (command === "dispatch") dispatchPackage(value); + else if (command === "download") console.log(await downloadPackage(value)); + else + throw new Error( + "Usage: package.mjs matrix|local-platform|package-local|package |build |collect |dispatch [branch]|download ", + ); +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/package.test.mjs b/scripts/package.test.mjs new file mode 100644 index 0000000..b704bde --- /dev/null +++ b/scripts/package.test.mjs @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + mkdtemp, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + assertHost, + buildArguments, + collectPackage, + dispatchPackage, + localPlatform, + packageMatrix, + verifyPackage, +} from "./package.mjs"; +import { targets } from "./release.mjs"; + +/** 为产物测试创建独立 Git 仓库和匹配当前版本的最小 Tauri 目录。 */ +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), "power-switch-package-")); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, "package.json"), '{"version":"0.1.7"}'); + execFileSync("git", ["init", "-q", root]); + execFileSync("git", ["-C", root, "add", "package.json"]); + execFileSync("git", [ + "-C", + root, + "-c", + "user.name=Package Test", + "-c", + "user.email=package@example.invalid", + "commit", + "-qm", + "fixture", + ]); + return root; +} + +/** 为指定平台生成一个假的安装包,测试收集而不实际编译应用。 */ +async function installer(root, name, extension, contents = "installer") { + const directory = join( + root, + "src-tauri", + "target", + targets[name].triple, + "release", + "bundle", + "fixture", + ); + await mkdir(directory, { recursive: true }); + const path = join(directory, `power-switch.${extension}`); + await writeFile(path, contents); + return path; +} + +test("maps all five native targets to runners, bundles and local hosts", () => { + const matrix = packageMatrix(); + assert.equal(matrix.include.length, 5); + assert.deepEqual( + new Set(matrix.include.map((entry) => entry.platform)), + new Set(Object.keys(targets)), + ); + assert.equal(localPlatform("darwin", "arm64"), "macos-universal"); + assert.equal(localPlatform("darwin", "x64"), "macos-universal"); + assert.equal(localPlatform("win32", "arm64"), "windows-arm64"); + assert.equal(localPlatform("linux", "x64"), "linux-x64"); + assert.throws(() => localPlatform("freebsd", "x64"), /Unsupported/); + assert.throws(() => assertHost("windows-arm64", "win32", "x64"), /requires/); + assert.throws(() => assertHost("linux-x64", "darwin", "arm64"), /requires/); + assert.throws(() => assertHost("unknown"), /Unknown/); + for (const name of Object.keys(targets)) { + const args = buildArguments(name); + assert.ok(args.includes(targets[name].triple)); + assert.ok(args.includes(targets[name].bundles)); + assert.ok(args.includes("--no-sign")); + assert.ok(args.includes('{"bundle":{"createUpdaterArtifacts":false}}')); + assert.deepEqual(args.slice(-2), ["--", "--locked"]); + } +}); + +test("collects each Linux installer and verifies the generated checksums", async (t) => { + const root = await fixture(t); + for (const extension of targets["linux-x64"].installers) + await installer(root, "linux-x64", extension); + const directory = await collectPackage("linux-x64", root); + await verifyPackage(directory, "linux-x64"); + const checksums = await readFile(join(directory, "SHA256SUMS"), "utf8"); + assert.equal(checksums.trim().split("\n").length, 3); + assert.match(directory, /v0\.1\.7-[a-f0-9]{12}-dirty/); + const name = checksums.split("\n")[0].slice(66); + await writeFile(join(directory, name), "tampered"); + await assert.rejects( + verifyPackage(directory, "linux-x64"), + /checksum failed/, + ); +}); + +test("missing, duplicate and empty installer files fail before staging", async (t) => { + const root = await fixture(t); + await assert.rejects(collectPackage("windows-x64", root), /ENOENT/); + const path = await installer(root, "windows-x64", "msi", ""); + await assert.rejects( + collectPackage("windows-x64", root), + /Invalid package file/, + ); + await writeFile(path, "msi"); + const other = join( + root, + "src-tauri", + "target", + targets["windows-x64"].triple, + "release", + "bundle", + "other", + ); + await mkdir(other); + await writeFile(join(other, "another.msi"), "msi"); + await assert.rejects(collectPackage("windows-x64", root), /Expected one/); +}); + +test( + "linked installer files are rejected", + { skip: process.platform === "win32" }, + async (t) => { + const root = await fixture(t); + const path = await installer(root, "windows-x64", "msi"); + const other = join( + root, + "src-tauri", + "target", + targets["windows-x64"].triple, + "release", + "bundle", + "other", + ); + await mkdir(other); + await symlink(path, join(other, "linked.msi")); + await assert.rejects( + collectPackage("windows-x64", root), + /Invalid package file/, + ); + }, +); + +test("cloud dispatch rejects a branch other than the checked-out branch", () => { + assert.throws( + () => dispatchPackage("another-branch"), + /Check out the branch/, + ); +}); + +test("package workflow only uploads test artifacts and does not publish a release", async () => { + const workflow = await readFile( + new URL("../.github/workflows/package.yml", import.meta.url), + "utf8", + ); + assert.match(workflow, /workflow_dispatch:/); + assert.match(workflow, /scripts\/package\.mjs matrix/); + assert.match(workflow, /scripts\/package\.mjs build/); + assert.match(workflow, /scripts\/package\.mjs collect/); + assert.doesNotMatch( + workflow, + /gh release|publish-release|contents: write|TAURI_SIGNING_PRIVATE_KEY/, + ); +}); + +test("test packaging targets stay aligned with the existing release matrix", async () => { + const workflow = await readFile( + new URL("../.github/workflows/release.yml", import.meta.url), + "utf8", + ); + for (const [name, target] of Object.entries(targets)) { + const entry = [ + `- platform: ${name}`, + `os: ${target.runner}`, + `target: ${target.triple}`, + `rust-targets: ${target.rustTargets}`, + `bundles: ${target.bundles}`, + ].join("\\s+"); + assert.match(workflow, new RegExp(entry)); + } +}); diff --git a/scripts/release.mjs b/scripts/release.mjs index 3dcf5e9..877bed4 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -16,22 +16,51 @@ export const targets = { "macos-universal": { triple: "universal-apple-darwin", extensions: ["dmg", "zip", "tar.gz"], + installers: ["dmg"], + bundles: "app,dmg", + runner: "macos-14", + rustTargets: "aarch64-apple-darwin,x86_64-apple-darwin", + host: "darwin", }, "windows-x64": { triple: "x86_64-pc-windows-msvc", extensions: ["msi", "zip"], + installers: ["msi"], + bundles: "msi", + runner: "windows-2022", + rustTargets: "x86_64-pc-windows-msvc", + host: "win32", + arch: "x64", }, "windows-arm64": { triple: "aarch64-pc-windows-msvc", extensions: ["msi", "zip"], + installers: ["msi"], + bundles: "msi", + runner: "windows-11-arm", + rustTargets: "aarch64-pc-windows-msvc", + host: "win32", + arch: "arm64", }, "linux-x64": { triple: "x86_64-unknown-linux-gnu", extensions: ["AppImage", "deb", "rpm"], + installers: ["AppImage", "deb", "rpm"], + bundles: "appimage,deb,rpm", + runner: "ubuntu-22.04", + rustTargets: "x86_64-unknown-linux-gnu", + host: "linux", + arch: "x64", }, "linux-arm64": { triple: "aarch64-unknown-linux-gnu", extensions: ["AppImage", "deb", "rpm"], + installers: ["AppImage", "deb", "rpm"], + bundles: "appimage,deb,rpm", + runner: "ubuntu-22.04-arm", + rustTargets: "aarch64-unknown-linux-gnu", + host: "linux", + arch: "arm64", }, };