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
28 changes: 28 additions & 0 deletions .github/workflows/build-desktop-tauri.yml
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,28 @@ jobs:
echo "Building Linux release bundles (deb, rpm, and AppImage)."
pnpm exec tauri build --bundles deb,rpm,appimage

- name: Sign Linux package updater artifacts
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
for package_type in deb rpm; do
packages=(src-tauri/target/release/bundle/"${package_type}"/*."${package_type}")
if [ "${#packages[@]}" -ne 1 ]; then
echo "Expected exactly one ${package_type} package, found ${#packages[@]}." >&2
exit 1
fi
# Tauri's automatic updater artifact signing only covers AppImage on Linux.
pnpm exec tauri signer sign "${packages[0]}"
if [ ! -s "${packages[0]}.sig" ]; then
echo "Expected a non-empty updater signature: ${packages[0]}.sig" >&2
exit 1
fi
done

- name: Verify Linux AppImage updater artifacts
shell: bash
run: |
Expand Down Expand Up @@ -252,7 +274,9 @@ jobs:
if-no-files-found: error
path: |
src-tauri/target/release/bundle/**/*.deb
src-tauri/target/release/bundle/**/*.deb.sig
src-tauri/target/release/bundle/**/*.rpm
src-tauri/target/release/bundle/**/*.rpm.sig
src-tauri/target/release/bundle/appimage/*.AppImage
src-tauri/target/release/bundle/appimage/*.AppImage.sig

Expand Down Expand Up @@ -722,6 +746,10 @@ jobs:
--tag "${RELEASE_TAG}" \
--version "${RELEASE_VERSION}" \
--channel "${manifest_channel}" \
--require-platform linux-x86_64-deb \
--require-platform linux-aarch64-deb \
--require-platform linux-x86_64-rpm \
--require-platform linux-aarch64-rpm \
"${manifest_args[@]}" \
--output "${manifest_output}"

Expand Down
68 changes: 68 additions & 0 deletions scripts/ci/build-desktop-tauri-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,58 @@ test('Linux workflow publishes signed AppImage updater artifacts', async () => {
assert.match(uploadStep.with?.path ?? '', /appimage\/\*\.AppImage\.sig/);
});

test('Linux package signing rejects missing, duplicate, unsigned, and failed outputs', async () => {
const workflow = await readWorkflowObject(WORKFLOW_FILE);
const steps = extractWorkflowJobSteps(workflow, BUILD_LINUX_JOB);
const signStep = findStep(steps, 'Linux package signing', (step) => step.name === 'Sign Linux package updater artifacts');
const uploadStep = findStep(steps, 'Linux upload', (step) => step.name === 'Upload artifacts');
assert.ok(steps.indexOf(signStep) < steps.indexOf(uploadStep));
assert.equal(signStep.env.TAURI_SIGNING_PRIVATE_KEY, '${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}');
assert.equal(signStep.env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD, '${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}');
for (const format of ['deb', 'rpm']) {
assert.ok(uploadStep.with.path.includes(`/**/*.${format}.sig`));
}

for (const scenario of ['valid', 'missing', 'duplicate', 'unsigned', 'failed']) {
const root = await mkdtemp(path.join(tmpdir(), 'astrbot-linux-signing-'));
try {
const bin = path.join(root, 'bin');
await mkdir(bin);
await writeFile(path.join(bin, 'pnpm'), `#!/bin/bash
set -euo pipefail
[[ "$1 $2 $3 $4" == 'exec tauri signer sign' ]]
[[ "$TAURI_SIGNING_PRIVATE_KEY" == 'test-key' ]]
[[ "$TEST_SCENARIO" != failed ]] || exit 1
[[ "$TEST_SCENARIO" != unsigned ]] || exit 0
printf 'test-signature' > "$5.sig"
`, { mode: 0o755 });
for (const format of ['deb', 'rpm']) {
const directory = path.join(root, 'src-tauri/target/release/bundle', format);
await mkdir(directory, { recursive: true });
if (scenario === 'missing' && format === 'rpm') continue;
await writeFile(path.join(directory, `AstrBot package.${format}`), 'package');
if (scenario === 'duplicate' && format === 'deb') {
await writeFile(path.join(directory, `extra.${format}`), 'package');
}
}
const result = spawnSync('bash', ['-c', signStep.run], {
cwd: root, encoding: 'utf8',
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, TAURI_SIGNING_PRIVATE_KEY: 'test-key', TEST_SCENARIO: scenario },
});
if (scenario === 'valid') {
assert.equal(result.status, 0, result.stderr);
for (const format of ['deb', 'rpm']) {
assert.equal(await readFile(path.join(root, 'src-tauri/target/release/bundle', format, `AstrBot package.${format}.sig`), 'utf8'), 'test-signature');
}
} else {
assert.notEqual(result.status, 0, scenario);
}
} finally {
await rm(root, { recursive: true, force: true });
}
}
});

test('macOS workflow packages a drag-to-Applications DMG alongside updater archives', async () => {
const workflowObject = await readWorkflowObject(WORKFLOW_FILE);
const steps = extractWorkflowJobSteps(workflowObject, BUILD_MACOS_JOB);
Expand Down Expand Up @@ -225,6 +277,15 @@ test('updater manifest generation uses GitHub for nightly and R2 for stable', as
await mkdir(artifactsRoot);
await writeFile(path.join(artifactsRoot, artifact), 'installer');
await writeFile(path.join(artifactsRoot, `${artifact}.sig`), 'test-signature');
const linuxPackages = [];
for (const arch of ['amd64', 'arm64']) {
for (const format of ['deb', 'rpm']) {
const filename = `AstrBot_4.19.2_linux_${arch}${nightly ? '_nightly_7ac169c5' : ''}.${format}`;
await writeFile(path.join(artifactsRoot, filename), 'linux-package');
await writeFile(path.join(artifactsRoot, `${filename}.sig`), `${arch}-${format}-signature`);
linuxPackages.push({ arch, format, filename });
}
}
const result = spawnSync('bash', ['-c', manifestStep.run], {
cwd: root,
encoding: 'utf8',
Expand All @@ -247,6 +308,13 @@ test('updater manifest generation uses GitHub for nightly and R2 for stable', as
: `https://releases.astrbot.app/desktop/releases/${version}/123-1`;
assert.equal(manifest.channel, channel);
assert.equal(manifest.platforms['windows-x86_64'].url, `${assetBase}/${artifact}`);
for (const { arch, format, filename } of linuxPackages) {
const target = `linux-${arch === 'amd64' ? 'x86_64' : 'aarch64'}-${format}`;
assert.deepEqual(manifest.platforms[target], {
url: `${assetBase}/${filename}`,
signature: `${arch}-${format}-signature`,
});
}
} finally {
await rm(root, { recursive: true, force: true });
}
Expand Down
34 changes: 34 additions & 0 deletions scripts/ci/generate_tauri_latest_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from scripts.ci.lib.release_artifacts import (
ARTIFACT_EXTENSIONS,
LINUX_APPIMAGE_UPDATER_PATTERNS,
LINUX_PACKAGE_UPDATER_PATTERNS,
MACOS_UPDATER_ARCHIVE_EXTENSION,
MACOS_UPDATER_ARCHIVE_PATTERNS,
MACOS_UPDATER_SIGNATURE_EXTENSION,
Expand Down Expand Up @@ -296,6 +297,32 @@ def collect_platforms(
)
continue

if sig_name.endswith((".deb.sig", ".rpm.sig")):
source_name = sig_name[:-4]
match = match_any(source_name, LINUX_PACKAGE_UPDATER_PATTERNS)
if not match:
raise ValueError(f"Unexpected Linux package artifact name: {source_name}")
arch = normalize_arch(match.group("arch"))
package = match.group("package")
_, base_version, nightly_suffix = derive_release_metadata(version, channel)
artifact_name = (
f"{match.group('name')}_{base_version}_linux_{arch}"
f"{nightly_suffix}.{package}"
)
# Keep installer-specific targets: a deb client cannot install AppImage/RPM bytes.
target_arch = "x86_64" if arch == "amd64" else "aarch64"
add_platform(
platforms,
f"linux-{target_arch}-{package}",
f"Linux {package}",
artifact_name,
sig_path,
repo,
tag,
asset_base_url,
)
continue

unsupported_signature_files.append(sig_name)

if unsupported_signature_files:
Expand All @@ -316,6 +343,10 @@ def main() -> int:
parser.add_argument("--channel", choices=["stable", "nightly"])
parser.add_argument("--output", required=True)
parser.add_argument("--notes", default="")
parser.add_argument(
"--require-platform", action="append", default=[],
help="Fail if this updater target is missing (repeatable).",
)
parser.add_argument(
"--asset-base-url",
help=(
Expand All @@ -341,6 +372,9 @@ def main() -> int:
)
if not platforms:
raise ValueError("No updater signatures found under artifacts root")
missing_platforms = sorted(set(args.require_platform) - platforms.keys())
if missing_platforms:
raise ValueError("Missing required updater platforms: " + ", ".join(missing_platforms))
except ValueError as exc:
raise SystemExit(str(exc)) from exc

Expand Down
19 changes: 19 additions & 0 deletions scripts/ci/lib/release_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
".app.tar.gz.sig",
".app.tar.gz",
".AppImage.sig",
".deb.sig",
".rpm.sig",
".exe.sig",
".msi.sig",
".zip.sig",
Expand Down Expand Up @@ -85,6 +87,23 @@
),
)

LINUX_PACKAGE_UPDATER_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(
rf"(?P<name>.+?)_(?P<version>{CANONICAL_VERSION_PATTERN})_linux_"
rf"(?P<arch>x86_64|x64|amd64|aarch64|arm64)"
rf"{CANONICAL_NIGHTLY_SUFFIX_PATTERN}\.(?P<package>deb|rpm)$"
),
# Original Tauri Debian and RPM bundle names, before normalization.
re.compile(
rf"(?P<name>.+?)_(?P<version>{LEGACY_VERSION_PATTERN})_"
rf"(?P<arch>x86_64|x64|amd64|aarch64|arm64)\.(?P<package>deb|rpm)$"
),
re.compile(
rf"(?P<name>.+?)-(?P<version>{VERSION_PATTERN})-\d+\."
rf"(?P<arch>x86_64|aarch64)\.(?P<package>rpm)$"
),
)


def match_any(
filename: str, patterns: tuple[re.Pattern[str], ...]
Expand Down
108 changes: 108 additions & 0 deletions scripts/ci/test_linux_package_updater.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import json
from pathlib import Path
import tempfile
import unittest
from unittest import mock

from scripts.ci import generate_tauri_latest_json as manifest
from scripts.ci import normalize_release_artifact_filenames as normalize
from scripts.ci.publish_r2_release import build_upload_plan


class LinuxPackageUpdaterTests(unittest.TestCase):
def test_signed_linux_packages_survive_normalization_manifest_and_r2_upload(self):
for channel in ("stable", "nightly"):
with self.subTest(channel=channel), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
version = "4.28.0"
suffix = ""
if channel == "nightly":
version += "-nightly.20260914.abcd1234"
suffix = "_nightly_abcd1234"
expected = {}
for arch, target_arch in (("amd64", "x86_64"), ("arm64", "aarch64")):
for package in ("deb", "rpm", "AppImage"):
directory = root / arch / package
directory.mkdir(parents=True)
if package == "rpm":
source = f"astrbot-desktop-{version}-1.{target_arch}.rpm"
else:
source = f"astrbot-desktop_{version}_{arch}.{package}"
(directory / source).write_bytes(b"installer")
signature = f"signature-{arch}-{package}"
(directory / f"{source}.sig").write_text(signature)
key = f"linux-{target_arch}-{package.lower()}"
expected[key] = (
f"AstrBot_4.28.0_linux_{arch}{suffix}.{package}", signature
)
with mock.patch("sys.argv", [
"normalize", "--root", str(root), "--build-mode", channel,
"--source-git-ref", "abcd1234", "--strict-unmatched",
]):
self.assertEqual(normalize.main(), 0)

tag = "nightly" if channel == "nightly" else "v4.28.0"
base = f"https://releases.astrbot.app/desktop/releases/{version}/123-1"
output = root / f"latest-{channel}.json"
args = [
"manifest", "--artifacts-root", str(root), "--repo", "AstrBotDevs/AstrBot-desktop",
"--tag", tag, "--version", version, "--channel", channel,
"--output", str(output),
]
if channel == "stable":
args += ["--asset-base-url", base]
for key in expected:
args += ["--require-platform", key]
with mock.patch("sys.argv", args):
self.assertEqual(manifest.main(), 0)
payload = json.loads(output.read_text())
self.assertEqual(set(payload["platforms"]), set(expected))
for key, (filename, signature) in expected.items():
url_base = base if channel == "stable" else (
f"https://github.com/AstrBotDevs/AstrBot-desktop/releases/download/{tag}"
)
self.assertEqual(payload["platforms"][key], {
"url": f"{url_base}/{filename}", "signature": signature,
})
if channel == "stable":
plan = build_upload_plan(root, output, version, "123-1", channel, "all")
self.assertEqual({item.source.name for item in plan}, {
name for filename, _ in expected.values()
for name in (filename, f"{filename}.sig")
} | {output.name})
self.assertEqual(plan[-1].key, "desktop/channels/stable/latest.json")
self.assertTrue(plan[-1].mutable)
self.assertTrue(all(not item.mutable for item in plan[:-1]))

def test_missing_linux_signature_blocks_manifest_publication(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "AstrBot_4.28.0_linux_amd64.AppImage.sig").write_text("appimage-signature")
(root / "AstrBot_4.28.0_linux_amd64.deb").write_bytes(b"unsigned-deb")
output = root / "latest.json"
with mock.patch("sys.argv", [
"manifest", "--artifacts-root", str(root), "--repo", "org/repo",
"--tag", "v4.28.0", "--version", "4.28.0", "--output", str(output),
"--require-platform", "linux-x86_64-deb",
]), self.assertRaisesRegex(SystemExit, "Missing required updater platforms: linux-x86_64-deb"):
manifest.main()
self.assertFalse(output.exists())

def test_duplicate_linux_package_target_is_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
for arch in ("amd64", "x86_64"):
(root / f"AstrBot_4.28.0_linux_{arch}.deb.sig").write_text("signature")
with self.assertRaisesRegex(ValueError, "Duplicate Linux deb artifact"):
manifest.collect_platforms(root, "org/repo", "v4.28.0", version="4.28.0", channel="stable")

def test_malformed_package_name_is_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "AstrBot_4.28.0_linux_ppc64le.deb.sig").write_text("signature")
with self.assertRaisesRegex(ValueError, "Unexpected Linux package artifact name"):
manifest.collect_platforms(root, "org/repo", "v4.28.0", version="4.28.0", channel="stable")


if __name__ == "__main__":
unittest.main()