From 0bb2e53f6ccdfc4689e6a33c47609aebc956ccf3 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 14 Sep 2026 21:06:19 +0800 Subject: [PATCH] fix(ci): publish signed Linux deb and rpm updater artifacts --- .github/workflows/build-desktop-tauri.yml | 28 +++++ .../ci/build-desktop-tauri-workflow.test.mjs | 68 +++++++++++ scripts/ci/generate_tauri_latest_json.py | 34 ++++++ scripts/ci/lib/release_artifacts.py | 19 +++ scripts/ci/test_linux_package_updater.py | 108 ++++++++++++++++++ 5 files changed, 257 insertions(+) create mode 100644 scripts/ci/test_linux_package_updater.py diff --git a/.github/workflows/build-desktop-tauri.yml b/.github/workflows/build-desktop-tauri.yml index bdcf6515..5ae89df9 100644 --- a/.github/workflows/build-desktop-tauri.yml +++ b/.github/workflows/build-desktop-tauri.yml @@ -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: | @@ -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 @@ -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}" diff --git a/scripts/ci/build-desktop-tauri-workflow.test.mjs b/scripts/ci/build-desktop-tauri-workflow.test.mjs index bce07106..010e7193 100644 --- a/scripts/ci/build-desktop-tauri-workflow.test.mjs +++ b/scripts/ci/build-desktop-tauri-workflow.test.mjs @@ -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); @@ -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', @@ -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 }); } diff --git a/scripts/ci/generate_tauri_latest_json.py b/scripts/ci/generate_tauri_latest_json.py index d3e73486..b3e54504 100644 --- a/scripts/ci/generate_tauri_latest_json.py +++ b/scripts/ci/generate_tauri_latest_json.py @@ -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, @@ -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: @@ -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=( @@ -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 diff --git a/scripts/ci/lib/release_artifacts.py b/scripts/ci/lib/release_artifacts.py index af1aee2c..c94707f5 100644 --- a/scripts/ci/lib/release_artifacts.py +++ b/scripts/ci/lib/release_artifacts.py @@ -9,6 +9,8 @@ ".app.tar.gz.sig", ".app.tar.gz", ".AppImage.sig", + ".deb.sig", + ".rpm.sig", ".exe.sig", ".msi.sig", ".zip.sig", @@ -85,6 +87,23 @@ ), ) +LINUX_PACKAGE_UPDATER_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile( + rf"(?P.+?)_(?P{CANONICAL_VERSION_PATTERN})_linux_" + rf"(?Px86_64|x64|amd64|aarch64|arm64)" + rf"{CANONICAL_NIGHTLY_SUFFIX_PATTERN}\.(?Pdeb|rpm)$" + ), + # Original Tauri Debian and RPM bundle names, before normalization. + re.compile( + rf"(?P.+?)_(?P{LEGACY_VERSION_PATTERN})_" + rf"(?Px86_64|x64|amd64|aarch64|arm64)\.(?Pdeb|rpm)$" + ), + re.compile( + rf"(?P.+?)-(?P{VERSION_PATTERN})-\d+\." + rf"(?Px86_64|aarch64)\.(?Prpm)$" + ), +) + def match_any( filename: str, patterns: tuple[re.Pattern[str], ...] diff --git a/scripts/ci/test_linux_package_updater.py b/scripts/ci/test_linux_package_updater.py new file mode 100644 index 00000000..a31b1365 --- /dev/null +++ b/scripts/ci/test_linux_package_updater.py @@ -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()