diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index a7d0869f1..dda117c8e 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -80,6 +80,12 @@ job trusts its runner with the selected environment's secrets. PR builds have no signing credentials and retain their existing branch-scoped caches. No persistent vendor disks or Docker builders are configured by these workflows. +Research Linux packaging builds AppImage and DEB through Tauri, then creates +the final RPM once from the normalized DEB payload. Its staging executable is +marked as RPM so Tauri selects the RPM updater entry and installer. Build and +download verifiers check the executable identity in all three package formats; +updater metadata, signing and publication keep their existing flow. + Research's Unix compiler-cache statistics use `scripts/ci/sccache-stats.sh`, which shares the build's cache configuration. In Actions the sccache socket lives under `RUNNER_TEMP`, so separate Nix shells reach the same server despite diff --git a/flake.nix b/flake.nix index cbe6db9b6..1d98420b1 100644 --- a/flake.nix +++ b/flake.nix @@ -206,6 +206,7 @@ atk bubblewrap cairo + cpio gdk-pixbuf glib glib-networking @@ -822,6 +823,8 @@ src = ./.; } '' cd "$src" + python3 scripts/ci/test_tauri_bundle_type.py + python3 scripts/ci/test_linux_rpm_packaging.py bash ./scripts/ci/test-release-gates.sh touch "$out" ''; diff --git a/scripts/ci/_common.sh b/scripts/ci/_common.sh index 7e03bc4c2..7971205eb 100755 --- a/scripts/ci/_common.sh +++ b/scripts/ci/_common.sh @@ -3686,11 +3686,12 @@ verify_linux_deb_package_executable_metadata() { tmp="$(mktemp -d)" status=0 ( - cd "${tmp}" - ar x "${deb}" - mkdir data - tar -xzf data.tar.gz -C data - verify_linux_package_executable_metadata "${tmp}/data/usr/bin/maple" + cd "${tmp}" && + ar x "${deb}" && + mkdir data && + tar -xzf data.tar.gz -C data && + verify_linux_package_executable_metadata "${tmp}/data/usr/bin/maple" && + "$(python3_runner)" "${SCRIPT_DIR}/tauri_bundle_type.py" --expect deb "${tmp}/data/usr/bin/maple" ) || status=$? rm -rf "${tmp}" return "${status}" @@ -3703,16 +3704,32 @@ verify_linux_appimage_executable_metadata() { tmp="$(mktemp -d)" status=0 ( - extract_appimage_tool "${appimage}" "${tmp}/AppDir" - verify_linux_package_executable_metadata "${tmp}/AppDir/usr/bin/maple" - verify_linux_appdir_runtime_closure "${tmp}/AppDir" - verify_linux_appdir_portable_runtime_paths "${tmp}/AppDir" + extract_appimage_tool "${appimage}" "${tmp}/AppDir" && + verify_linux_package_executable_metadata "${tmp}/AppDir/usr/bin/maple" && + "$(python3_runner)" "${SCRIPT_DIR}/tauri_bundle_type.py" --expect appimage "${tmp}/AppDir/usr/bin/maple" && + verify_linux_appdir_runtime_closure "${tmp}/AppDir" && + verify_linux_appdir_portable_runtime_paths "${tmp}/AppDir" && verify_linux_appdir_webkit_runtime "${tmp}/AppDir" ) || status=$? rm -rf "${tmp}" return "${status}" } +verify_linux_rpm_package_executable_metadata() { + local rpm="$1" + local tmp status + + tmp="$(mktemp -d)" + status=0 + ( + rpm2cpio "${rpm}" | cpio -i --quiet --to-stdout './usr/bin/maple' > "${tmp}/maple" && + verify_linux_package_executable_metadata "${tmp}/maple" && + "$(python3_runner)" "${SCRIPT_DIR}/tauri_bundle_type.py" --expect rpm "${tmp}/maple" + ) || status=$? + rm -rf "${tmp}" + return "${status}" +} + verify_linux_desktop_package_metadata() { local artifact @@ -3724,6 +3741,9 @@ verify_linux_desktop_package_metadata() { *.deb) verify_linux_deb_package_executable_metadata "${artifact}" ;; + *.rpm) + verify_linux_rpm_package_executable_metadata "${artifact}" + ;; esac done } @@ -3753,17 +3773,19 @@ normalize_deb_package() { extract_deb_payload() { local deb="$1" local payload="$2" - local tmp + local tmp status tmp="$(mktemp -d)" + status=0 ( - cd "${tmp}" - ar x "${deb}" - mkdir -p "${payload}" + cd "${tmp}" && + ar x "${deb}" && + mkdir -p "${payload}" && tar -xzf data.tar.gz -C "${payload}" - ) + ) || status=$? rm -rf "${tmp}" + return "${status}" } touch_tree_to_source_date_epoch() { @@ -3786,14 +3808,14 @@ rpm_arch() { esac } -rebuild_rpm_package_from_payload() { +build_rpm_package_from_payload() { local rpm="$1" local payload="$2" local topdir spec built_rpm local version release arch rpm_build_shell command -v rpmbuild >/dev/null 2>&1 || { - echo "rpmbuild is required to normalize RPM packages. Run through the flake CI shell." >&2 + echo "rpmbuild is required to build RPM packages. Run through the flake CI shell." >&2 return 1 } rpm_build_shell="$(command -v bash)" @@ -3848,7 +3870,10 @@ EOF --define "_buildhost (none)" \ --define "use_source_date_epoch_as_buildtime 1" \ --define "_build_name_fmt %{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}.rpm" \ - --define "_binary_payload w9.gzdio" + --define "_binary_payload w9.gzdio" || { + rm -rf "${topdir}" + return 1 + } built_rpm="$(find "${topdir}/RPMS" -type f -name '*.rpm' | LC_ALL=C sort | head -n 1)" if [ -z "${built_rpm}" ]; then @@ -3857,7 +3882,10 @@ EOF return 1 fi - mv "${built_rpm}" "${rpm}" + mv "${built_rpm}" "${rpm}" || { + rm -rf "${topdir}" + return 1 + } rm -rf "${topdir}" } @@ -3866,35 +3894,33 @@ normalize_linux_desktop_packages() { return 0 fi - local deb rpm payload + local deb rpm payload status local -a debs=() - local -a rpms=() while IFS= read -r -d '' deb; do debs+=("${deb}") done < <(find "${TAURI_DIR}/target/release/bundle/deb" -type f -name '*.deb' -print0 2>/dev/null | LC_ALL=C sort -z) - while IFS= read -r -d '' rpm; do - rpms+=("${rpm}") - done < <(find "${TAURI_DIR}/target/release/bundle/rpm" -type f -name '*.rpm' -print0 2>/dev/null | LC_ALL=C sort -z) - - for deb in "${debs[@]}"; do - normalize_deb_package "${deb}" - done - - if [ "${#rpms[@]}" -gt 0 ]; then - if [ "${#debs[@]}" -eq 0 ]; then - echo "Cannot normalize RPM packages without a Debian payload source." >&2 - return 1 - fi - - payload="$(mktemp -d)" - extract_deb_payload "${debs[0]}" "${payload}" - for rpm in "${rpms[@]}"; do - rebuild_rpm_package_from_payload "${rpm}" "${payload}" - done - rm -rf "${payload}" + if [ "${#debs[@]}" -ne 1 ]; then + echo "Linux packaging requires exactly one Debian payload source." >&2 + return 1 fi + + normalize_deb_package "${debs[0]}" + rpm="${TAURI_DIR}/target/release/bundle/rpm/$(jq -r --arg arch "$(rpm_arch)" \ + '"Maple-\(.version)-\(.bundle.linux.rpm.release // "1").\($arch).rpm"' "${TAURI_DIR}/tauri.conf.json")" + mkdir -p "$(dirname "${rpm}")" + payload="$(mktemp -d)" + status=0 + ( + extract_deb_payload "${debs[0]}" "${payload}" && + # Tauri selects both the update artifact and installer from this marker. + # Change only the RPM staging copy; the normalized DEB keeps its identity. + "$(python3_runner)" "${SCRIPT_DIR}/tauri_bundle_type.py" --expect deb --set rpm "${payload}/usr/bin/maple" && + build_rpm_package_from_payload "${rpm}" "${payload}" + ) || status=$? + rm -rf "${payload}" + return "${status}" } linux_tauri_pr_config() { @@ -3910,7 +3936,7 @@ linux_tauri_pr_config() { bundle: { createUpdaterArtifacts: false, useLocalToolsDir: true, - targets: ["appimage", "deb", "rpm"], + targets: ["appimage", "deb"], linux: { appimage: { bundleMediaFramework: true, @@ -3922,11 +3948,6 @@ linux_tauri_pr_config() { files: { "/usr/lib/maple/libonnxruntime.so": $ort } - }, - rpm: { - files: { - "/usr/lib/maple/libonnxruntime.so": $ort - } } } } @@ -3952,7 +3973,7 @@ linux_tauri_release_config() { bundle: { createUpdaterArtifacts: true, useLocalToolsDir: true, - targets: ["appimage", "deb", "rpm"], + targets: ["appimage", "deb"], linux: { appimage: { bundleMediaFramework: true, @@ -3964,11 +3985,6 @@ linux_tauri_release_config() { files: { "/usr/lib/maple/libonnxruntime.so": $ort } - }, - rpm: { - files: { - "/usr/lib/maple/libonnxruntime.so": $ort - } } } } diff --git a/scripts/ci/release-verification-guide.sh b/scripts/ci/release-verification-guide.sh index e95eb818c..84ba2247e 100755 --- a/scripts/ci/release-verification-guide.sh +++ b/scripts/ci/release-verification-guide.sh @@ -100,7 +100,7 @@ That command verifies portable hashes and skips host-specific checks that cannot \`\`\`bash # Linux desktop installers: verifies final hashes, Tauri updater signatures, -# AppImage executable metadata, and .deb executable metadata. +# AppImage/DEB/RPM executable metadata and package-specific updater identity. nix develop --no-update-lock-file .#desktop-linux -c ./scripts/ci/verify-release-artifacts.sh artifacts linux # macOS desktop installers: run on macOS with Xcode selected. diff --git a/scripts/ci/tauri_bundle_type.py b/scripts/ci/tauri_bundle_type.py new file mode 100644 index 000000000..9911d42be --- /dev/null +++ b/scripts/ci/tauri_bundle_type.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Verify Tauri's package marker; correct it when repacking a DEB payload as RPM.""" + +import argparse +from pathlib import Path +import sys + + +# Tauri's bundler patches this fixed-length token in the packaged executable. +PREFIX = b"__TAURI_BUNDLE_TYPE_VAR_" +SUFFIXES = {"deb": b"DEB", "rpm": b"RPM", "appimage": b"APP"} + + +def verify_bundle_type(path, expected, replacement=None): + if replacement is not None and (expected, replacement) != ("deb", "rpm"): + raise ValueError("only the DEB-to-RPM transition is supported") + if path.is_symlink() or not path.is_file(): + raise ValueError("expected a regular executable file") + + data = path.read_bytes() + if data.count(PREFIX) != 1: + raise ValueError("expected exactly one Tauri bundle marker") + offset = data.index(PREFIX) + len(PREFIX) + suffix = data[offset:offset + 3] + if len(suffix) != 3: + raise ValueError("truncated Tauri bundle marker") + if suffix not in SUFFIXES.values(): + raise ValueError("unrecognized Tauri bundle marker") + if suffix != SUFFIXES[expected]: + raise ValueError(f"expected {expected} bundle marker, found {suffix.decode().lower()}") + + if replacement is not None: + # Patch only the three-byte suffix, retaining all other bytes and mode. + with path.open("r+b") as stream: + stream.seek(offset) + stream.write(SUFFIXES[replacement]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--expect", required=True, choices=SUFFIXES) + parser.add_argument("--set", choices=["rpm"], dest="replacement") + parser.add_argument("file", type=Path) + args = parser.parse_args() + try: + verify_bundle_type(args.file, args.expect, args.replacement) + except (OSError, ValueError) as error: + print(f"Tauri bundle type check failed: {error}", file=sys.stderr) + return 1 + kind = args.replacement or args.expect + print(f"verified-tauri-bundle-type {kind} {args.file}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/test_linux_rpm_packaging.py b/scripts/ci/test_linux_rpm_packaging.py new file mode 100644 index 000000000..0466a3cbb --- /dev/null +++ b/scripts/ci/test_linux_rpm_packaging.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Exercise RPM staging identity and packaging failures without native builds.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +class RpmPackagingTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.tauri = self.root / "tauri" + self.deb = self.tauri / "target/release/bundle/deb/Maple.deb" + self.deb.parent.mkdir(parents=True) + self.deb.write_bytes(b"fixture\0__TAURI_BUNDLE_TYPE_VAR_DEB\0payload") + (self.tauri / "tauri.conf.json").write_text('{"version":"1.2.3"}') + self.rpm = self.tauri / "target/release/bundle/rpm/Maple-1.2.3-1.x86_64.rpm" + self.bin = self.root / "bin" + self.bin.mkdir() + + def run_shell(self, commands): + env = dict(os.environ, PATH=f"{self.bin}:{os.environ['PATH']}", + FIXTURE_TAURI=str(self.tauri), SOURCE_DATE_EPOCH="1234567890") + return subprocess.run( + ["bash", "-c", 'source scripts/ci/_common.sh\n' + 'TAURI_DIR="$FIXTURE_TAURI"\n' + 'host_os() { echo linux; }\n' + 'rpm_arch() { echo x86_64; }\n' + commands], + cwd=ROOT, env=env, capture_output=True, text=True, + ) + + def test_normalization_preserves_deb_and_marks_only_rpm_copy(self): + original = self.deb.read_bytes() + result = self.run_shell(''' +normalize_deb_package() { :; } +extract_deb_payload() { + mkdir -p "$2/usr/bin" + cp "$1" "$2/usr/bin/maple" +} +build_rpm_package_from_payload() { cp "$2/usr/bin/maple" "$1"; } +normalize_linux_desktop_packages +''') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.deb.read_bytes(), original) + self.assertEqual(self.rpm.read_bytes(), original.replace(b"VAR_DEB", b"VAR_RPM")) + + def test_invalid_staging_identity_never_builds_rpm(self): + self.deb.write_bytes(b"missing bundle identity") + result = self.run_shell(''' +normalize_deb_package() { :; } +extract_deb_payload() { + mkdir -p "$2/usr/bin" + cp "$1" "$2/usr/bin/maple" +} +build_rpm_package_from_payload() { touch "$1"; } +normalize_linux_desktop_packages +''') + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.rpm.exists()) + + def test_failed_extraction_is_not_hidden_by_cleanup(self): + result = self.run_shell(''' +ar() { return 19; } +if extract_deb_payload "${TAURI_DIR}/target/release/bundle/deb/Maple.deb" "$TAURI_DIR/payload"; then + exit 0 +else + exit "$?" +fi +''') + self.assertEqual(result.returncode, 19) + + def fake_rpmbuild(self, exit_code): + tool = self.bin / "rpmbuild" + tool.write_text(f'''#!/usr/bin/env bash +set -eu +while [ "$#" -gt 0 ]; do + if [ "$1" = --define ]; then + case "$2" in "_topdir "*) topdir="${{2#_topdir }}" ;; esac + shift + fi + shift +done +mkdir -p "$topdir/RPMS/x86_64" +printf 'partial RPM' > "$topdir/RPMS/x86_64/maple-1.2.3-1.x86_64.rpm" +exit {exit_code} +''') + tool.chmod(0o755) + + def run_factory(self, extra=""): + return self.run_shell(extra + ''' +mkdir -p "$TAURI_DIR/payload/usr/bin" "$TAURI_DIR/target/release/bundle/rpm" +printf 'fixture' > "$TAURI_DIR/payload/usr/bin/maple" +if build_rpm_package_from_payload "$TAURI_DIR/target/release/bundle/rpm/Maple-1.2.3-1.x86_64.rpm" "$TAURI_DIR/payload"; then + exit 0 +else + exit "$?" +fi +''') + + def test_failed_rpmbuild_cannot_publish_partial_output(self): + self.fake_rpmbuild(19) + result = self.run_factory() + self.assertNotEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.rpm.exists()) + + def test_failed_final_move_is_not_hidden_by_cleanup(self): + self.fake_rpmbuild(0) + result = self.run_factory('mv() { return 19; }\n') + self.assertNotEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.rpm.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_tauri_bundle_type.py b/scripts/ci/test_tauri_bundle_type.py new file mode 100644 index 000000000..30f83bd2e --- /dev/null +++ b/scripts/ci/test_tauri_bundle_type.py @@ -0,0 +1,107 @@ +"""Fail-closed package identity checks and the bounded DEB-to-RPM patch.""" + +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import unittest + + +HELPER = Path(__file__).with_name("tauri_bundle_type.py") +PREFIX = b"__TAURI_BUNDLE_TYPE_VAR_" + + +class TauriBundleTypeTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.directory = Path(directory.name) + self.executable = self.directory / "maple" + + def run_helper(self, *arguments): + return subprocess.run( + [sys.executable, "-I", str(HELPER), *arguments, str(self.executable)], + capture_output=True, text=True, check=False, + ) + + def write_executable(self, data): + self.executable.write_bytes(data) + self.executable.chmod(0o751) + + def test_each_supported_kind_verifies_without_writing(self): + for kind, suffix in (("deb", b"DEB"), ("rpm", b"RPM"), ("appimage", b"APP")): + with self.subTest(kind=kind): + data = b"\x7fELF\x00before" + PREFIX + suffix + b"\x00after\xff" + self.write_executable(data) + before = self.executable.stat() + result = self.run_helper("--expect", kind) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.executable.read_bytes(), data) + after = self.executable.stat() + self.assertEqual(after.st_mtime_ns, before.st_mtime_ns) + self.assertEqual(stat.S_IMODE(after.st_mode), 0o751) + + def test_patch_changes_only_marker_suffix_and_preserves_mode(self): + before = b"\x7fELF\x00before" + PREFIX + b"DEB\x00after\xff" + expected = b"\x7fELF\x00before" + PREFIX + b"RPM\x00after\xff" + self.write_executable(before) + result = self.run_helper("--expect", "deb", "--set", "rpm") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.executable.read_bytes(), expected) + self.assertEqual(len(expected), len(before)) + self.assertEqual(stat.S_IMODE(self.executable.stat().st_mode), 0o751) + self.assertEqual(self.run_helper("--expect", "rpm").returncode, 0) + self.assertNotEqual(self.run_helper("--expect", "deb").returncode, 0) + self.assertEqual(self.executable.read_bytes(), expected) + + def test_invalid_source_markers_never_mutate_file(self): + for data in ( + b"no marker", + PREFIX, + PREFIX + b"DE", + PREFIX + b"UNK", + PREFIX + b"XYZ", + PREFIX + b"RPM", + PREFIX + b"APP", + PREFIX + b"DEB\x00" + PREFIX + b"DEB", + PREFIX + b"DEB\x00" + PREFIX + b"RPM", + PREFIX + b"DEB\x00" + PREFIX, + ): + with self.subTest(data=data): + self.write_executable(data) + result = self.run_helper("--expect", "deb", "--set", "rpm") + self.assertNotEqual(result.returncode, 0) + self.assertIn("Tauri bundle type check failed", result.stderr) + self.assertEqual(self.executable.read_bytes(), data) + self.assertEqual(stat.S_IMODE(self.executable.stat().st_mode), 0o751) + + def test_invalid_transition_and_missing_expected_kind_do_not_write(self): + data = PREFIX + b"RPM" + for arguments in ( + ("--expect", "rpm", "--set", "rpm"), + ("--expect", "appimage", "--set", "rpm"), + ("--set", "rpm"), + ): + with self.subTest(arguments=arguments): + self.write_executable(data) + self.assertNotEqual(self.run_helper(*arguments).returncode, 0) + self.assertEqual(self.executable.read_bytes(), data) + + def test_missing_nonregular_and_symlink_files_are_rejected(self): + self.assertNotEqual(self.run_helper("--expect", "deb").returncode, 0) + self.executable.mkdir() + self.assertNotEqual(self.run_helper("--expect", "deb").returncode, 0) + self.executable.rmdir() + original = self.directory / "original" + data = PREFIX + b"DEB" + original.write_bytes(data) + self.executable.symlink_to(original) + result = self.run_helper("--expect", "deb", "--set", "rpm") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(original.read_bytes(), data) + self.assertTrue(self.executable.is_symlink()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/verify-release-artifacts.sh b/scripts/ci/verify-release-artifacts.sh index b0743353a..df59d77f5 100755 --- a/scripts/ci/verify-release-artifacts.sh +++ b/scripts/ci/verify-release-artifacts.sh @@ -413,9 +413,10 @@ verify_tauri_signatures_in_artifacts() { } verify_linux_installer_metadata() { - local appimage deb + local appimage deb rpm local -a appimages=() local -a debs=() + local -a rpms=() if [ "$(host_os)" != "linux" ]; then if [ "${allow_platform_skips}" = "1" ]; then @@ -434,6 +435,10 @@ verify_linux_installer_metadata() { debs+=("${deb}") done < <(find "${artifacts_dir}" -type f -name '*.deb' -print0 | LC_ALL=C sort -z) + while IFS= read -r -d '' rpm; do + rpms+=("${rpm}") + done < <(find "${artifacts_dir}" -type f -name '*.rpm' -print0 | LC_ALL=C sort -z) + if [ "${#appimages[@]}" -eq 0 ]; then echo "No Linux Maple AppImage artifact found for installer metadata verification." >&2 return 1 @@ -444,6 +449,11 @@ verify_linux_installer_metadata() { return 1 fi + if [ "${#rpms[@]}" -eq 0 ]; then + echo "No Linux .rpm artifact found for installer metadata verification." >&2 + return 1 + fi + for appimage in "${appimages[@]}"; do verify_linux_appimage_executable_metadata "${appimage}" done @@ -451,6 +461,10 @@ verify_linux_installer_metadata() { for deb in "${debs[@]}"; do verify_linux_deb_package_executable_metadata "${deb}" done + + for rpm in "${rpms[@]}"; do + verify_linux_rpm_package_executable_metadata "${rpm}" + done } verify_linux_manifest() {