feat(release): build and publish an rpm of Studio for Linux

Fedora, RHEL and openSUSE cannot install the .deb. electron-builder now
also builds an .rpm, signed and mirrored with the same minisign flow, and
studio-manifest lists it under native_packages[linux-<arch>-rpm] beside the
unchanged .deb key. fpm spells the architecture of an .rpm name x86_64; the
packaging hook renames it to the release architecture and fails on any
spelling it does not know. The package job reads the rpm back for its name,
the helper's owner and mode, and the rpm's post-uninstall leaves the command
link alone on upgrade.
This commit is contained in:
yhh committed 2026-10-10 11:13:32 -07:00
1 parent 365b5ce0dd
commit 0a09f6c035
14 files changed
+279 -22

No files matched your search

+11 -10
View File
@@ -210,12 +210,13 @@ jobs:
desktop/frontend-next/pnpm-lock.yaml
desktop/electron/pnpm-lock.yaml
# electron-builder shells out to dpkg-deb through fakeroot for the .deb,
# and carries its own NSIS -- the toolchain the Wails line installed by
# hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is not this shell's.
# electron-builder shells out to dpkg-deb through fakeroot for the .deb and
# to rpmbuild for the .rpm, and carries its own NSIS -- the toolchain the
# Wails line installed by hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is
# not this shell's.
- name: Install Linux packaging deps
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y fakeroot
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm
# The same Developer ID and notary credentials the desktop line uses: one
# certificate signs any bundle identifier its team owns, so Studio does not
@@ -1068,12 +1069,12 @@ jobs:
find staging/cli-archives -type f -print0)
ls -la dist
# Two Windows artifacts (installer, portable zip) per architecture,
# two per macOS architecture (dmg, zip) and the Linux .deb. Fewer
# two per macOS architecture (dmg, zip) and the Linux .deb and .rpm. Fewer
# means an artifact was lost between jobs, and a partial release is
# worse than a failed one.
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
if [ "$count" -lt 9 ]; then
echo "::error::expected 9 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb), found $count"
if [ "$count" -lt 10 ]; then
echo "::error::expected 10 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb and .rpm), found $count"
exit 1
fi
# Each platform's update channel, and there is exactly one per
@@ -1086,7 +1087,7 @@ jobs:
# the version it is running with nothing saying so.
# Each Windows architecture by name for the same reason: a glob passes
# on whichever installer shipped.
for required in 'ReasonixStudio-linux-*.deb' \
for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-linux-*.rpm' \
'ReasonixStudio-windows-amd64-installer.exe' 'ReasonixStudio-windows-arm64-installer.exe' \
'ReasonixStudio-windows-amd64.zip' 'ReasonixStudio-windows-arm64.zip' \
'ReasonixStudio-darwin-amd64.zip' 'ReasonixStudio-darwin-arm64.zip'; do
@@ -1182,8 +1183,8 @@ jobs:
fi
{
printf '%s\n\n' '---'
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** 从 \`.deb\` 安装。\n\n" "$windows"
printf '%s\n' '三个平台都能自更新:Linux 走自己的包,Windows 运行下一个安装器,macOS 替换自身 bundle。'
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。\n\n" "$windows"
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
} >> "$notes"
assets=()
for f in dist/*; do
+28 -2
View File
@@ -256,10 +256,11 @@ jobs:
cache: pnpm
cache-dependency-path: desktop/electron/pnpm-lock.yaml
# electron-builder shells out to dpkg-deb through fakeroot for the .deb.
# electron-builder shells out to dpkg-deb through fakeroot for the .deb and
# to rpmbuild for the .rpm.
- name: Install Linux packaging deps
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y fakeroot
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm
- name: Build the SPA
working-directory: desktop/frontend-next
@@ -433,6 +434,31 @@ jobs:
exit 1
fi
# The .rpm is read back for the same two properties as the .deb: it is the
# package update.StudioLine names, and the helper pkexec runs as root is
# owned by root and writable by no one else, or any local user could
# replace it.
- name: The .rpm is Studio's own package and carries what updates it
if: runner.os == 'Linux'
run: |
rpm=$(ls dist/ReasonixStudio-linux-*.rpm)
name=$(rpm -qp --qf '%{NAME}' "$rpm")
if [ "$name" != "reasonix-studio" ]; then
echo "::error::the package is $name; rpm holds that beside the installed reasonix-studio instead of upgrading it"
exit 1
fi
rpm -qp --qf '[%{FILEMODES:perms} %{FILEUSERNAME} %{FILEGROUPNAME} %{FILENAMES}\n]' "$rpm" > contents.txt
if ! grep -qxE -- '-rwxr-xr-x root root /usr/lib/reasonix-studio/reasonix-studio-update-helper' contents.txt; then
echo "::error::no root-owned, executable, not-writable update helper at the path the polkit action names"
grep reasonix-studio-update-helper contents.txt || echo "(not in the package at all)"
exit 1
fi
if ! grep -qE -- '^-r[w-][x-]r-[x-]r-[x-] root root /usr/share/polkit-1/actions/io.reasonix.studio.update.policy$' contents.txt; then
echo "::error::the polkit action is missing, or is not a root-owned file no one else can write"
grep polkit contents.txt || echo "(not in the package at all)"
exit 1
fi
# The Electron shell sets the package's glibc floor; a Go binary linked
# with cgo would raise it to the runner's own.
- name: The .deb's Go binaries are statically linked
+3 -2
View File
@@ -191,12 +191,13 @@ func TestPublishRefusesAPartialWindowsSet(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
publish := mappingValue(jobsOf(t, root), "publish")
run := mappingScalar(stepNamed(t, publish, "Collect artifacts"), "run")
if !strings.Contains(run, `"$count" -lt 9`) {
t.Error("publish does not require at least 9 ReasonixStudio-* packages")
if !strings.Contains(run, `"$count" -lt 10`) {
t.Error("publish does not require at least 10 ReasonixStudio-* packages")
}
for _, name := range []string{
"ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-arm64-installer.exe",
"ReasonixStudio-windows-amd64.zip", "ReasonixStudio-windows-arm64.zip",
"ReasonixStudio-linux-*.deb", "ReasonixStudio-linux-*.rpm",
} {
if !strings.Contains(run, "'"+name+"'") {
t.Errorf("publish does not require %s by name", name)
+33 -1
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"reasonix/internal/platform/update"
@@ -13,7 +14,8 @@ import (
// archMap is desktop/electron/arch.json: what the packager builds for, and what
// each of those is called in a release.
type archMap struct {
Map map[string]string `json:"map"`
Map map[string]string `json:"map"`
FPM map[string][]string `json:"fpm"`
}
func loadArchMap(t *testing.T) archMap {
@@ -93,3 +95,33 @@ func TestThePackagerSpellingResolvesToAKeyNobodyAsksFor(t *testing.T) {
}
}
}
// fpm names an .rpm for the architecture its own way, and the parser accepts
// that name too: it keys a package nobody asks for. The packager hook renames
// these spellings, so every one must be a non-canonical key here.
func TestFPMSpellingOfAnRPMIsNotAReleaseKey(t *testing.T) {
m := loadArchMap(t)
if len(m.FPM) == 0 {
t.Fatal("arch.json lists no fpm spellings")
}
canonical := map[string]bool{}
for _, to := range m.Map {
canonical[to] = true
}
for from, spellings := range m.FPM {
to, ok := m.Map[from]
if !ok {
t.Errorf("fpm spells %s, which map does not release", from)
continue
}
if got, ok := rpmPackageKey(artifactPrefix + "linux-" + to + ".rpm"); !ok || got != update.RPMPackageKey("linux", to) {
t.Errorf("linux %s rpm = %q (%v)", to, got, ok)
}
for _, spelled := range spellings {
got, ok := rpmPackageKey(artifactPrefix + "linux-" + spelled + ".rpm")
if !ok || canonical[strings.TrimSuffix(strings.TrimPrefix(got, "linux-"), "-rpm")] {
t.Errorf("%s resolved to %q (%v); the rename is no longer load-bearing", spelled, got, ok)
}
}
}
}
+22 -1
View File
@@ -84,6 +84,12 @@ func run(dir, version, tag, deltaDir string, mirrored bool) error {
m.NativePackages[key] = asset
fmt.Printf("native package: %s -> %s\n", name, key)
}
// The .rpm rides beside the .deb under its own key: the deb's key is the
// one every installed client already reads.
if key, ok := rpmPackageKey(name); ok {
m.NativePackages[key] = asset
fmt.Printf("native package: %s -> %s\n", name, key)
}
// Windows updates by running the next installer, so that is what the
// platform lookup has to resolve. The portable archive is never listed:
// resolving it would hand the updater an artifact it cannot install.
@@ -135,7 +141,8 @@ func releaseAsset(repo, tag, name string, mirrored bool) update.Asset {
func installable(name string) bool {
return strings.HasSuffix(name, ".dmg") ||
strings.HasSuffix(name, "-installer.exe") ||
strings.HasSuffix(name, ".deb")
strings.HasSuffix(name, ".deb") ||
strings.HasSuffix(name, ".rpm")
}
// macArchiveKeys maps a macOS archive to the platforms it can install. The
@@ -185,6 +192,20 @@ func nativePackageKey(name string) (string, bool) {
return update.PlatformKey("linux", arch), true
}
// rpmPackageKey maps a ReasonixStudio-linux-<arch>.rpm artifact to its
// native_packages key.
func rpmPackageKey(name string) (string, bool) {
base, ok := strings.CutSuffix(name, ".rpm")
if !ok {
return "", false
}
arch, ok := strings.CutPrefix(base, artifactPrefix+"linux-")
if !ok || arch == "" {
return "", false
}
return update.RPMPackageKey("linux", arch), true
}
func hashFile(path string) (int64, string, error) {
f, err := os.Open(path)
if err != nil {
+54
View File
@@ -232,3 +232,57 @@ func TestWindowsArchitecturesResolveToTheirOwnInstallers(t *testing.T) {
t.Errorf("no delta directory was given, yet deltas = %v", m.Deltas)
}
}
// Every installed Linux client reads native_packages["linux-amd64"] as the
// .deb and hands it to dpkg. The .rpm may add a key and never change that one.
func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
write := func(dir string, names ...string) {
for _, name := range names {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
}
generate := func(dir string) update.Manifest {
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", true); err != nil {
t.Fatalf("run: %v", err)
}
var m update.Manifest
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
debOnly, both := t.TempDir(), t.TempDir()
write(debOnly, "ReasonixStudio-linux-amd64.deb")
write(both, "ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm")
before, after := generate(debOnly), generate(both)
if after.NativePackages["linux-amd64"] != before.NativePackages["linux-amd64"] {
t.Errorf("the deb entry moved: %+v, was %+v", after.NativePackages["linux-amd64"], before.NativePackages["linux-amd64"])
}
if !strings.HasSuffix(after.NativePackages["linux-amd64"].URL, "linux-amd64.deb") {
t.Errorf("native_packages[linux-amd64] = %+v, want the .deb", after.NativePackages["linux-amd64"])
}
rpm, ok := after.NativePackages["linux-amd64-rpm"]
if !ok || !strings.HasSuffix(rpm.URL, "ReasonixStudio-linux-amd64.rpm") || rpm.Sig != rpm.URL+".minisig" {
t.Errorf("native_packages[linux-amd64-rpm] = %+v, want the signed .rpm", rpm)
}
if len(after.NativePackages) != 2 {
t.Errorf("native packages = %v, want the deb and the rpm", after.NativePackages)
}
if _, ok := after.Platforms["linux-amd64"]; ok {
t.Error("the .rpm must not resolve as a platform asset")
}
if _, ok := after.Downloads["ReasonixStudio-linux-amd64.rpm"]; !ok {
t.Error("downloads must offer the .rpm to a person")
}
if len(after.Downloads) != len(before.Downloads)+1 {
t.Errorf("downloads = %v, want only the rpm added", after.Downloads)
}
}
+9
View File
@@ -4,5 +4,14 @@
"x64": "amd64",
"arm64": "arm64",
"universal": "universal"
},
"_fpm": "fpm spells the architecture its own way in an .rpm name (x86_64, aarch64) wherever artifactName uses ${arch}; the deb comes out as amd64 already. Each spelling is renamed to the release arch, and one not listed here fails the build.",
"fpm": {
"x64": [
"x86_64"
],
"arm64": [
"aarch64"
]
}
}
+20
View File
@@ -96,6 +96,7 @@ mac:
linux:
target:
- deb
- rpm
icon: assets/icon.png
category: Development
maintainer: esengine <[email protected]>
@@ -129,3 +130,22 @@ deb:
- root
- linux/reasonix-studio-update-helper=/usr/lib/reasonix-studio/reasonix-studio-update-helper
- ../packaging/linux/io.reasonix.studio.update.policy=/usr/share/polkit-1/actions/io.reasonix.studio.update.policy
rpm:
# The same package name as the deb, so a distribution's package database
# names one product under one identifier.
packageName: reasonix-studio
# electron-builder's own script removes the command link whatever $1 says, and
# rpm runs it for the old package after the new one installed its link.
afterRemove: ../packaging/linux/rpm-after-remove.sh
# The same two system-path files as the deb, for the same reasons, with the
# owner stated for the same reason: the rpm spec records whoever ran the build
# unless told, and polkit will not execute a helper its invoker could have
# written.
fpm:
- --rpm-user
- root
- --rpm-group
- root
- linux/reasonix-studio-update-helper=/usr/lib/reasonix-studio/reasonix-studio-update-helper
- ../packaging/linux/io.reasonix.studio.update.policy=/usr/share/polkit-1/actions/io.reasonix.studio.update.policy
+7 -2
View File
@@ -1,6 +1,6 @@
"use strict";
const path = require("node:path");
const { map } = require("../arch.json");
const { map, fpm } = require("../arch.json");
// What each artifact was built for, keyed by the path electron-builder wrote.
// Taken from the Arch enum it hands the hook rather than read back out of the
@@ -26,4 +26,9 @@ function canonical(arch) {
return to;
}
module.exports = { built, record, canonical, map };
// Every spelling electron-builder or fpm may put in a file name for arch.
function spellings(arch) {
return [arch, ...(fpm[arch] ?? [])];
}
module.exports = { built, record, canonical, spellings, map, fpm };
@@ -1,12 +1,21 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { built, canonical, map } = require("./arch");
const { built, canonical, spellings, map, fpm } = require("./arch");
// The archs whose electron-builder spelling differs from the release one. A
// name still carrying one of these got past the rename, which is the failure
// this refuses to ship rather than report.
const foreign = Object.keys(map).filter((from) => map[from] !== from);
const foreign = [
...Object.keys(map).filter((from) => map[from] !== from),
...Object.values(fpm).flat(),
];
// names reports whether a file name carries arch as a whole segment, which is
// how both the rename and the leftover check recognise it.
function names(name, arch) {
return name.includes(`-${arch}.`) || name.includes(`-${arch}-`);
}
// plan records the rename of one artifact and of everything written beside it
// under its name — a .blockmap left behind points at a file that no longer
@@ -38,7 +47,9 @@ module.exports = async function canonicalArtifactNames(buildResult) {
const arch = built.get(path.resolve(file));
if (!arch) continue;
const to = canonical(arch);
if (to !== arch) plan(path.resolve(file), arch, to, renames);
const name = path.basename(file);
const from = spellings(arch).find((s) => names(name, s));
if (from && from !== to) plan(path.resolve(file), from, to, renames);
}
for (const [from, to] of renames) fs.renameSync(from, to);
@@ -53,7 +64,7 @@ module.exports = async function canonicalArtifactNames(buildResult) {
}
for (const file of out) {
const name = path.basename(file);
const stray = foreign.find((a) => name.includes(`-${a}.`) || name.includes(`-${a}-`));
const stray = foreign.find((a) => names(name, a));
if (stray) {
throw new Error(`${name} still names ${stray}; studio-manifest reads artifact names as GOARCH`);
}
+43
View File
@@ -1772,3 +1772,46 @@ test("the notice names where the logs are in both languages and shows how it end
assert.match(en.detail, /code 2/);
assert.match(zh.detail, /2/);
});
test("packaging: an rpm fpm spelled x86_64 leaves the build as the release arch", async () => {
const arch = require("../packaging/arch.js");
const hook = require("../packaging/canonical-artifact-names.js");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rpm-name-"));
const deb = path.join(dir, "ReasonixStudio-linux-amd64.deb");
const rpm = path.join(dir, "ReasonixStudio-linux-x86_64.rpm");
fs.writeFileSync(deb, "d");
fs.writeFileSync(rpm, "r");
arch.record(deb, "x64");
arch.record(rpm, "x64");
const out = await hook({ artifactPaths: [deb, rpm] });
assert.deepEqual(out.map((f) => path.basename(f)), ["ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm"]);
assert.ok(fs.existsSync(path.join(dir, "ReasonixStudio-linux-amd64.rpm")));
assert.ok(!fs.existsSync(rpm));
});
test("packaging: a name still carrying an fpm spelling is refused", async () => {
const arch = require("../packaging/arch.js");
const hook = require("../packaging/canonical-artifact-names.js");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rpm-name-"));
const stray = path.join(dir, "ReasonixStudio-linux-aarch64.rpm");
fs.writeFileSync(stray, "r");
arch.record(stray, "x64");
await assert.rejects(hook({ artifactPaths: [stray] }), /aarch64/);
});
test("packaging: a Windows installer named for x64 is renamed to amd64", async () => {
const arch = require("../packaging/arch.js");
const hook = require("../packaging/canonical-artifact-names.js");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "win-name-"));
const installer = path.join(dir, "ReasonixStudio-windows-x64-installer.exe");
const zip = path.join(dir, "ReasonixStudio-windows-x64.zip");
for (const f of [installer, zip]) {
fs.writeFileSync(f, "x");
arch.record(f, "x64");
}
const out = await hook({ artifactPaths: [installer, zip] });
assert.deepEqual(
out.map((f) => path.basename(f)),
["ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-amd64.zip"],
);
});
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# rpm runs the old package's post-uninstall after the new package's
# post-install, with the number of installed copies left as $1. An upgrade
# leaves one, and removing the link then would delete what the new package just
# made.
[ "$1" -ge 1 ] 2>/dev/null && exit 0
if type update-alternatives >/dev/null 2>&1; then
update-alternatives --remove '${executable}' '/opt/${sanitizedProductName}/${executable}'
else
rm -f '/usr/bin/${executable}'
fi
APPARMOR_PROFILE_DEST='/etc/apparmor.d/${executable}'
if [ -f "$APPARMOR_PROFILE_DEST" ]; then
if apparmor_status --enabled > /dev/null 2>&1; then
if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then
apparmor_parser --remove "$APPARMOR_PROFILE_DEST" || true
fi
fi
rm -f "$APPARMOR_PROFILE_DEST"
fi
+6
View File
@@ -286,6 +286,12 @@ Studio and 1.x signing jobs and smoke tests share the concurrency group `certum-
`latest.json` keys `platforms` (and `deltas`) as `<GOOS>-<GOARCH>`, derived from the artifact name by `studio-manifest` and asked for by the running kernel through `runtime.GOOS` and `runtime.GOARCH`.
- The key is the binary's own architecture, not the machine's. An x64 build running under ARM64 emulation asks for `windows-amd64` and keeps updating to the amd64 installer; it is not moved to a native build.
- Linux ships a `.deb` and an `.rpm`, both named `ReasonixStudio-linux-<arch>.<ext>`.
- `native_packages["linux-<arch>"]` is the `.deb` and has to stay that: every installed client hands it to dpkg.
- The `.rpm` is `native_packages["linux-<arch>-rpm"]`. No shipped client reads it yet, so an `.rpm` install is updated by downloading the new package.
- The `.rpm` carries no rpm GPG signature, and `dnf install` of a local file does not check one by default.
- Its integrity rests on HTTPS and the minisign signature beside it. Verify with `minisign -Vm ReasonixStudio-linux-amd64.rpm -P RWSw66n0RsoSr6Zhh6qt5YO95YkpCayTOCMFVDNUQSjJYwxoYngNVBSq`, the key compiled into `internal/platform/update/verify.go`.
- fpm spells the architecture in an `.rpm` name its own way (`x86_64`, `aarch64`); `desktop/electron/packaging/canonical-artifact-names.js` renames it to the release architecture and fails the build on any spelling left over.
- When a client tries to install a release whose manifest lacks its key (and, on Linux, a matching `native_packages` key), staging fails with `update.no_package` naming the release page; no other architecture's package is substituted. A missing `deltas` entry means the full package is downloaded.
A notes upload that fails does not fail `publish`: the step warns, `::warning::release notes for vX.Y.Z were not uploaded`, and the catalog entry is written without a `notes` field. Run the backfill (section 9) afterwards to attach them.
+4
View File
@@ -76,6 +76,10 @@ func sources(primary, fallback string) []string {
// way, so lookups always agree.
func PlatformKey(goos, goarch string) string { return goos + "-" + goarch }
// RPMPackageKey is the native_packages key of an .rpm. It is not PlatformKey:
// that key already names the .deb, which every installed client reads.
func RPMPackageKey(goos, goarch string) string { return PlatformKey(goos, goarch) + "-rpm" }
// CurrentPlatform is PlatformKey for the running binary.
func CurrentPlatform() string { return PlatformKey(runtime.GOOS, runtime.GOARCH) }