Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 60 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@
#
# Builds installers for every platform defined in the electron-builder config
# (macOS dmg/zip arm64 and x64, Windows NSIS and portable ZIP x64, Linux
# AppImage/deb/rpm x64), each
# AppImage/deb/rpm x64 and arm64), each
# with its native pi-desktop-host-core Rust sidecar. The Linux app.asar is also
# exported as a system-Electron repackaging asset, and all outputs — with their
# electron-updater latest*.yml feeds — are published to the GitHub Release
# (D126/D285/D364/D603). macOS tag artifacts are Developer ID-signed, notarized, and
# stapled before upload (D450 / ADR 0289).
#
# It also builds the headless `pi-host` bundle for every Linux platform the
# desktop publishes (currently x64) and publishes it as
# It also builds the headless `pi-host` bundle for both Linux architectures the
# desktop publishes and publishes each as
# pi-host-<version>-linux-<arch>.tar.gz plus a .sha256 sidecar. The desktop's
# SSH bootstrap downloads that asset at the desktop's version and verifies the
# published checksum before installing on the remote machine (rollout
Expand Down Expand Up @@ -118,8 +118,22 @@ jobs:
# (24.04) would raise the floor to 2.39.
os: ubuntu-22.04
arch: x64
runner_arch: x86_64
artifact: linux-x64
platform: linux
feed: latest-linux.yml
dist: dist:linux
- name: Linux arm64
# GitHub's native arm64 runner packages the Electron app and the
# host-core sidecar at the same architecture; the `-arm` image
# keeps the glibc 2.35 floor the x64 lane documents
# (ubuntu-24.04-arm would raise it to 2.39).
Comment on lines +126 to +130
os: ubuntu-22.04-arm
arch: arm64
runner_arch: aarch64
artifact: linux-arm64
platform: linux
feed: latest-linux-arm64.yml
dist: dist:linux
runs-on: ${{ matrix.os }}
timeout-minutes: 60
Expand All @@ -129,8 +143,12 @@ jobs:
steps:
- uses: actions/checkout@v7

- name: Verify native macOS runner architecture
if: matrix.platform == 'macos'
- name: Verify native runner architecture
# A mislabelled or emulated runner would wrap one architecture's
# Electron app around the other architecture's Rust sidecar. The
# Windows rows carry no `runner_arch` because electron-builder
# cross-builds Windows from any runner.
if: matrix.runner_arch
run: |
set -euo pipefail
actual_arch="$(uname -m)"
Expand Down Expand Up @@ -323,7 +341,23 @@ jobs:

- name: Export Linux ASAR release asset
if: matrix.platform == 'linux'
run: node scripts/export-linux-asar.mjs
run: node scripts/export-linux-asar.mjs --arch ${{ matrix.arch }}

- name: Verify the Linux update feed
# electron-builder names the Linux feed `latest-linux.yml` for x64 and
# `latest-linux-<arch>.yml` for every other Linux architecture — the
# same names electron-updater requests on those architectures — so the
# two lanes cannot overwrite each other's feed when the publish job
# merges their artifacts.
if: matrix.platform == 'linux'
run: |
set -euo pipefail
feed="apps/desktop/release/${{ matrix.feed }}"
if [[ ! -f "$feed" ]]; then
echo "::error::Expected the Linux updater feed at $feed"
ls apps/desktop/release || true
exit 1
fi

- name: Verify macOS artifact names
if: matrix.platform == 'macos'
Expand Down Expand Up @@ -377,11 +411,17 @@ jobs:
fail-fast: false
matrix:
include:
# Every Linux platform the desktop release publishes. The desktop
# matrix ships Linux x64 only today; add arm64 here in lockstep if
# the desktop starts publishing it.
# Every Linux platform the desktop release publishes. Keep this list
# in lockstep with the desktop build matrix above and with
# PUBLISHED_TARGETS in apps/desktop/electron/main/remote/pi-host-release.ts:
# a bundle the desktop refuses to install is dead weight, and a target
# the desktop offers without a bundle fails mid-download.
- arch: x64
os: ubuntu-22.04
runner_arch: x86_64
- arch: arm64
os: ubuntu-22.04-arm
runner_arch: aarch64
runs-on: ${{ matrix.os }}
timeout-minutes: 60
defaults:
Expand All @@ -400,6 +440,17 @@ jobs:
exit 1
fi

- name: Verify native runner architecture
if: matrix.runner_arch
run: |
set -euo pipefail
actual_arch="$(uname -m)"
expected_arch="${{ matrix.runner_arch }}"
if [ "$actual_arch" != "$expected_arch" ]; then
echo "::error::Expected $expected_arch runner, got $actual_arch."
exit 1
fi

- uses: pnpm/action-setup@v6

- uses: actions/setup-node@v7
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@ Agent / Plan / Goal
| macOS | Apple Silicon | `.dmg` / `.zip` |
| macOS | Intel | `.dmg` / `.zip` |
| Windows | x64 | Installer / `.zip` |
| Linux | x64 | `.AppImage` / `.deb` / `.rpm` / `.asar` |
| Linux | x64 / ARM64 | `.AppImage` / `.deb` / `.rpm` / `.asar` |

macOS releases are signed with a Developer ID certificate and notarized by Apple.

Expand All @@ -584,7 +584,7 @@ macOS releases are signed with a Developer ID certificate and notarized by Apple

<br />

Linux x64 packages require **glibc 2.35+**.
Linux packages require **glibc 2.35+**.

Common supported distributions include:

Expand Down
4 changes: 2 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ Agent / Plan / Goal
| macOS | Apple Silicon | `.dmg` / `.zip` |
| macOS | Intel | `.dmg` / `.zip` |
| Windows | x64 | 安装程序 / `.zip` |
| Linux | x64 | `.AppImage` / `.deb` / `.rpm` / `.asar` |
| Linux | x64 / ARM64 | `.AppImage` / `.deb` / `.rpm` / `.asar` |

macOS Release 使用 Developer ID 签名并经过 Apple Notarization。

Expand All @@ -588,7 +588,7 @@ macOS Release 使用 Developer ID 签名并经过 Apple Notarization。

<br />

Linux x64 需要 **glibc 2.35+**。
Linux 软件包(x64 与 arm64)需要 **glibc 2.35+**。

常见支持版本:

Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/electron/main/remote/pi-host-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ export type PiHostTarget = {
};

/**
* Release-matrix parity. `release.yml`'s `pi-host-bundle` job publishes Linux
* x64 only today; the desktop matrix ships no other Linux platform. A target
* Release-matrix parity. `release.yml`'s `pi-host-bundle` job publishes the
* Linux architectures the desktop build matrix ships (x64 and arm64). A target
* outside this list is refused with a typed failure instead of a 404 halfway
* through a download.
*/
const PUBLISHED_TARGETS: readonly string[] = ["linux-x64"];
const PUBLISHED_TARGETS: readonly string[] = ["linux-x64", "linux-arm64"];

export function targetKey(target: PiHostTarget): string {
return `${target.platform}-${target.arch}`;
Expand Down
16 changes: 4 additions & 12 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -254,24 +254,16 @@
"linux": {
"executableName": "pi-desktop",
"syncDesktopName": true,
"artifactName": "PI-Desktop-${version}-linux-${arch}.${ext}",
"target": [
{
"target": "AppImage",
"arch": [
"x64"
]
"target": "AppImage"
},
{
"target": "deb",
"arch": [
"x64"
]
"target": "deb"
},
{
"target": "rpm",
"arch": [
"x64"
]
"target": "rpm"
}
],
"category": "Development",
Expand Down
58 changes: 55 additions & 3 deletions apps/desktop/test/ci-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -150,19 +150,71 @@ test("manual Linux package validation covers the RPM desktop identity", () => {
test("release workflow publishes the Linux ASAR beside installers", () => {
assert.match(
releaseWorkflowSource,
/if: matrix\.platform == 'linux'[\s\S]*?node scripts\/export-linux-asar\.mjs/,
/if: matrix\.platform == 'linux'[\s\S]*?node scripts\/export-linux-asar\.mjs --arch \$\{\{ matrix\.arch \}\}/,
);
assert.match(releaseWorkflowSource, /apps\/desktop\/release\/\*\.asar/);
assert.match(
releaseAsarScriptSource,
/linux-unpacked\/resources\/app\.asar/,
/linuxUnpackedDirName\(releaseArch\)\}\/resources\/app\.asar/,
);
assert.match(
releaseAsarScriptSource,
/PI-Desktop-\$\{releaseVersion\}-linux-x64\.asar/,
/PI-Desktop-\$\{releaseVersion\}-linux-\$\{releaseArch\}\.asar/,
);
});

test("the release matrix ships native Linux x64 and arm64 lanes", () => {
assert.match(
releaseWorkflowSource,
/name: Linux arm64[\s\S]*?os: ubuntu-22\.04-arm[\s\S]*?arch: arm64[\s\S]*?runner_arch: aarch64[\s\S]*?artifact: linux-arm64[\s\S]*?platform: linux[\s\S]*?dist: dist:linux/,
"the arm64 lane runs on a native arm64 runner",
);
// A runner that is not the architecture it packages would pair one
// architecture's Electron app with the other's Rust sidecar.
assert.match(
releaseWorkflowSource,
/name: Verify native runner architecture[\s\S]*?if: matrix\.runner_arch/,
);
// electron-builder already names each Linux lane's feed after its own
// architecture (`latest-linux.yml` on x64, `latest-linux-arm64.yml` on
// arm64) — the names electron-updater requests — so the lanes cannot
// overwrite each other's feed during the publish merge, and the lane
// verifies the name it will publish instead of renaming it.
assert.match(
releaseWorkflowSource,
/name: Verify the Linux update feed[\s\S]*?feed="apps\/desktop\/release\/\$\{\{ matrix\.feed \}\}"/,
);
assert.match(releaseWorkflowSource, /feed: latest-linux\.yml/);
assert.match(releaseWorkflowSource, /feed: latest-linux-arm64\.yml/);
// The pi-host bundles the SSH bootstrap installs track the desktop's Linux
// lanes one for one.
assert.match(
releaseWorkflowSource,
/pi-host-bundle:[\s\S]*?- arch: x64[\s\S]*?os: ubuntu-22\.04[\s\S]*?- arch: arm64[\s\S]*?os: ubuntu-22\.04-arm/,
);
});

test("the Linux package config lets the workflow choose the architecture", () => {
const build = JSON.parse(desktopPackageSource).build;
// electron-builder prefers a target's configured arch list over the CLI
// `--x64`/`--arm64` flag, so pinning both here would make each Linux lane
// build the other architecture around its own native host-core.
for (const entry of build.linux.target) {
assert.equal(
entry.arch,
undefined,
`linux target ${entry.target} must not pin an arch`,
);
}
assert.match(
build.linux.artifactName,
/-linux-\$\{arch\}\.\$\{ext\}$/,
"the AppImage name carries its architecture",
);
assert.equal(build.deb.artifactName, "pi-desktop_${version}_${arch}.${ext}");
assert.equal(build.rpm.artifactName, "pi-desktop-${version}-${arch}.${ext}");
});

test("release matrix packages both native macOS architectures", () => {
assert.match(
releaseWorkflowSource,
Expand Down
53 changes: 47 additions & 6 deletions apps/desktop/test/release-asar.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,22 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";

import { exportLinuxAsar } from "../../../scripts/export-linux-asar.mjs";
import {
exportLinuxAsar,
linuxUnpackedDirName,
} from "../../../scripts/export-linux-asar.mjs";

async function createFixture() {
async function createFixture({ unpackedDir = "linux-unpacked" } = {}) {
const rootDir = await mkdtemp(join(tmpdir(), "pi-desktop-release-asar-"));
const releaseDir = join(rootDir, "apps/desktop/release");
await mkdir(join(releaseDir, "linux-unpacked/resources"), { recursive: true });
await mkdir(join(releaseDir, `${unpackedDir}/resources`), { recursive: true });
await writeFile(
join(rootDir, "apps/desktop/package.json"),
JSON.stringify({ version: "9.8.7" }),
);
const sourcePath = join(
releaseDir,
"linux-unpacked/resources/app.asar",
`${unpackedDir}/resources/app.asar`,
);
await writeFile(sourcePath, "fixture-asar-bytes");
return { rootDir, releaseDir, sourcePath };
Expand All @@ -25,7 +28,7 @@ async function createFixture() {
test("exports the exact Linux app.asar with the release asset name", async () => {
const fixture = await createFixture();
try {
const result = await exportLinuxAsar({ rootDir: fixture.rootDir });
const result = await exportLinuxAsar({ rootDir: fixture.rootDir, arch: "x64" });
const destination = join(
fixture.releaseDir,
"PI-Desktop-9.8.7-linux-x64.asar",
Expand All @@ -41,12 +44,50 @@ test("exports the exact Linux app.asar with the release asset name", async () =>
}
});

test("arm64 exports the arm64 asset from electron-builder's arm64 unpacked tree", async () => {
// electron-builder only drops the architecture suffix for x64, so reading
// `linux-unpacked` on the arm64 lane would export nothing at all.
assert.equal(linuxUnpackedDirName("x64"), "linux-unpacked");
assert.equal(linuxUnpackedDirName("arm64"), "linux-arm64-unpacked");

const fixture = await createFixture({ unpackedDir: "linux-arm64-unpacked" });
try {
const result = await exportLinuxAsar({
rootDir: fixture.rootDir,
arch: "arm64",
});

assert.equal(
result.destination,
join(fixture.releaseDir, "PI-Desktop-9.8.7-linux-arm64.asar"),
);
assert.equal(
await readFile(result.destination, "utf8"),
await readFile(fixture.sourcePath, "utf8"),
);
} finally {
await rm(fixture.rootDir, { recursive: true, force: true });
}
});

test("refuses an architecture the release matrix does not publish", async () => {
const fixture = await createFixture();
try {
await assert.rejects(
exportLinuxAsar({ rootDir: fixture.rootDir, arch: "riscv64" }),
/Unsupported Linux release architecture: riscv64/,
);
} finally {
await rm(fixture.rootDir, { recursive: true, force: true });
}
});

test("fails when electron-builder did not produce the Linux app.asar", async () => {
const fixture = await createFixture();
try {
await rm(fixture.sourcePath);
await assert.rejects(
exportLinuxAsar({ rootDir: fixture.rootDir }),
exportLinuxAsar({ rootDir: fixture.rootDir, arch: "x64" }),
/Linux ASAR source not found:/,
);
} finally {
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/test/remote-host-pi-host-release.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,14 @@ test("resolveTarget refuses a platform or architecture the release cannot serve"
assert.equal(resolveTarget(""), null);
});

test("isPublishedTarget matches the release matrix, which ships linux-x64 only", () => {
test("isPublishedTarget matches the release matrix's Linux architectures", () => {
assert.equal(targetKey(LINUX_X64), "linux-x64");
assert.equal(isPublishedTarget(LINUX_X64), true);
// The desktop matrix publishes Linux x64 and arm64, and release.yml's
// pi-host-bundle job builds one bundle per lane.
assert.equal(isPublishedTarget({ platform: "linux", arch: "arm64" }), true);
// Publishing parity: a 404 halfway through a download is what this guard
// exists to prevent, so every unpublished cell must be refused here.
assert.equal(isPublishedTarget({ platform: "linux", arch: "arm64" }), false);
assert.equal(isPublishedTarget({ platform: "darwin", arch: "x64" }), false);
assert.equal(isPublishedTarget({ platform: "darwin", arch: "arm64" }), false);
});
Expand Down
Loading
Loading