mirror of
https://github.com/storytold/craft-common-actions.git
synced 2026-11-06 02:22:15 +00:00
408 lines
19 KiB
Python
408 lines
19 KiB
Python
"""Unit tests for Arch Linux packaging action and workflow integration."""
|
|
|
|
import io
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
import tempfile
|
|
import unittest
|
|
|
|
import yaml
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
RELEASE_WORKFLOW = ROOT / ".github/workflows/release.yml"
|
|
ACTION_YML = ROOT / "actions/arch-package/action.yml"
|
|
ARCH_SCRIPT = ROOT / "actions/arch-package/arch-package.sh"
|
|
|
|
|
|
class ArchWorkflowTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.data = yaml.load(RELEASE_WORKFLOW.read_text(), Loader=yaml.BaseLoader)
|
|
|
|
def test_has_arch_input_defined(self):
|
|
inputs = self.data["on"]["workflow_call"]["inputs"]
|
|
self.assertIn("has-arch", inputs)
|
|
self.assertEqual(inputs["has-arch"]["type"], "boolean")
|
|
self.assertEqual(inputs["has-arch"]["default"], "true")
|
|
|
|
def test_arch_job_structure(self):
|
|
jobs = self.data["jobs"]
|
|
self.assertIn("arch", jobs)
|
|
arch_job = jobs["arch"]
|
|
self.assertEqual(arch_job["if"], "inputs.has-arch")
|
|
self.assertEqual(arch_job["needs"], ["version", "linux"])
|
|
self.assertEqual(arch_job["runs-on"], "ubuntu-24.04")
|
|
self.assertEqual(arch_job["strategy"]["matrix"]["arch"], ["x86_64", "aarch64"])
|
|
|
|
def test_release_job_needs_arch(self):
|
|
release_job = self.data["jobs"]["release"]
|
|
self.assertIn("arch", release_job["needs"])
|
|
|
|
|
|
class ArchActionTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.action_data = yaml.safe_load(ACTION_YML.read_text())
|
|
|
|
def test_action_inputs(self):
|
|
inputs = self.action_data["inputs"]
|
|
self.assertIn("tarball", inputs)
|
|
self.assertTrue(inputs["tarball"]["required"])
|
|
self.assertIn("arch", inputs)
|
|
self.assertIn("output-dir", inputs)
|
|
self.assertIn("license", inputs)
|
|
|
|
def test_arch_script_syntax(self):
|
|
self.assertTrue(ARCH_SCRIPT.exists())
|
|
self.assertTrue(os.access(ARCH_SCRIPT, os.X_OK))
|
|
res = subprocess.run(["bash", "-n", str(ARCH_SCRIPT)], capture_output=True, text=True)
|
|
self.assertEqual(res.returncode, 0, f"Syntax error: {res.stderr}")
|
|
|
|
def test_arch_script_requires_tarball(self):
|
|
res = subprocess.run([str(ARCH_SCRIPT)], capture_output=True, text=True)
|
|
self.assertNotEqual(res.returncode, 0)
|
|
self.assertIn("usage", res.stderr)
|
|
|
|
def test_arch_script_missing_tarball(self):
|
|
res = subprocess.run([str(ARCH_SCRIPT), "/nonexistent.tar.gz"], capture_output=True, text=True)
|
|
self.assertNotEqual(res.returncode, 0)
|
|
self.assertIn("not found", res.stderr)
|
|
|
|
def _create_fixture_tarball(self, tmp_path, filename="myapp-1.2.3-linux-x86_64.tar.gz",
|
|
app_name="myapp", desktop_name=None):
|
|
tar_root = tmp_path / "stage_src"
|
|
(tar_root / "bin").mkdir(parents=True, exist_ok=True)
|
|
(tar_root / "share/applications").mkdir(parents=True, exist_ok=True)
|
|
(tar_root / f"bin/{app_name}").write_text("#!/bin/sh\necho hello\n")
|
|
os.chmod(tar_root / f"bin/{app_name}", 0o755)
|
|
desktop = desktop_name or f"ai.storyteller.{app_name}.desktop"
|
|
(tar_root / f"share/applications/{desktop}").write_text(
|
|
f"[Desktop Entry]\nName={app_name}\nExec={app_name}\nType=Application\n"
|
|
)
|
|
tarball = tmp_path / filename
|
|
with tarfile.open(tarball, "w:gz") as tar:
|
|
for item in tar_root.iterdir():
|
|
tar.add(item, arcname=item.name)
|
|
return tarball
|
|
|
|
def _setup_mock_tools(self, tmp_path, makepkg_body=None):
|
|
tools = tmp_path / "tools"
|
|
tools.mkdir(parents=True, exist_ok=True)
|
|
for tool in ("bsdtar", "zstd", "tar"):
|
|
t = tools / tool
|
|
t.write_text("#!/bin/sh\nexit 0\n")
|
|
t.chmod(0o755)
|
|
mp = tools / "makepkg"
|
|
body = makepkg_body or """#!/bin/bash
|
|
# Mock makepkg: copies generated PKGBUILD and makepkg.conf to output, creates dummy package
|
|
cp PKGBUILD "$MOCK_CAPTURE/PKGBUILD"
|
|
cp makepkg.conf "$MOCK_CAPTURE/makepkg.conf"
|
|
touch "myapp-1.2.3-1-x86_64.pkg.tar.zst"
|
|
exit 0
|
|
"""
|
|
mp.write_text(body)
|
|
mp.chmod(0o755)
|
|
return tools
|
|
|
|
def test_unsupported_architecture_rejected(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
tarball = self._create_fixture_tarball(tmp_path)
|
|
tools = self._setup_mock_tools(tmp_path)
|
|
for bad_arch in ("armv7l", "i686", "x86_64; bad", "$(touch injected)", "mips"):
|
|
with self.subTest(arch=bad_arch):
|
|
res = subprocess.run(
|
|
[str(ARCH_SCRIPT), str(tarball), bad_arch, str(tmp_path / "out")],
|
|
capture_output=True,
|
|
text=True,
|
|
env={**os.environ, "PATH": f"{tools}:{os.environ['PATH']}"},
|
|
)
|
|
self.assertNotEqual(res.returncode, 0)
|
|
self.assertIn("unsupported architecture", res.stderr)
|
|
|
|
def test_invalid_version_rejected(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
tools = self._setup_mock_tools(tmp_path)
|
|
for bad_ver in ("1.2.3;id", "1.2.3'injection", "1.2.3`touch injected`"):
|
|
tarball = self._create_fixture_tarball(tmp_path, filename=f"myapp-{bad_ver}-linux-x86_64.tar.gz")
|
|
res = subprocess.run(
|
|
[str(ARCH_SCRIPT), str(tarball), "x86_64", str(tmp_path / "out")],
|
|
capture_output=True,
|
|
text=True,
|
|
env={**os.environ, "PATH": f"{tools}:{os.environ['PATH']}"},
|
|
)
|
|
self.assertNotEqual(res.returncode, 0)
|
|
self.assertIn("invalid version", res.stderr)
|
|
|
|
def test_invalid_pkgrel_rejected(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
tarball = self._create_fixture_tarball(tmp_path)
|
|
tools = self._setup_mock_tools(tmp_path)
|
|
for bad_rel in ("0", "01", "-1", "1;bad", "1.0.1", "rel"):
|
|
with self.subTest(pkgrel=bad_rel):
|
|
res = subprocess.run(
|
|
[str(ARCH_SCRIPT), str(tarball), "x86_64", str(tmp_path / "out")],
|
|
capture_output=True,
|
|
text=True,
|
|
env={**os.environ, "PATH": f"{tools}:{os.environ['PATH']}", "PKGREL": bad_rel},
|
|
)
|
|
self.assertNotEqual(res.returncode, 0)
|
|
self.assertIn("invalid PKGREL", res.stderr)
|
|
|
|
def test_invalid_license_rejected(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
tarball = self._create_fixture_tarball(tmp_path)
|
|
tools = self._setup_mock_tools(tmp_path)
|
|
for bad_lic in ("MIT OR ", "MIT; touch injected", "MIT | id", "MIT OR (Apache-2.0)"):
|
|
with self.subTest(license=bad_lic):
|
|
res = subprocess.run(
|
|
[str(ARCH_SCRIPT), str(tarball), "x86_64", str(tmp_path / "out")],
|
|
capture_output=True,
|
|
text=True,
|
|
env={**os.environ, "PATH": f"{tools}:{os.environ['PATH']}", "ARCH_LICENSE": bad_lic},
|
|
)
|
|
self.assertNotEqual(res.returncode, 0)
|
|
self.assertTrue(any(msg in res.stderr for msg in (
|
|
"invalid license identifier", "empty license component",
|
|
"invalid characters in license", "trailing operator in license",
|
|
"leading operator in license"
|
|
)), f"Unexpected stderr: {res.stderr}")
|
|
|
|
def test_pkgbuild_and_makepkg_conf_safe_serialization(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
tarball = self._create_fixture_tarball(tmp_path)
|
|
tools = self._setup_mock_tools(tmp_path)
|
|
capture = tmp_path / "capture"
|
|
capture.mkdir()
|
|
res = subprocess.run(
|
|
[str(ARCH_SCRIPT), str(tarball), "x86_64", str(tmp_path / "out")],
|
|
capture_output=True,
|
|
text=True,
|
|
env={
|
|
**os.environ,
|
|
"PATH": f"{tools}:{os.environ['PATH']}",
|
|
"MOCK_CAPTURE": str(capture),
|
|
"ARCH_LICENSE": "MIT OR Apache-2.0",
|
|
"PKGREL": "2",
|
|
},
|
|
)
|
|
self.assertEqual(res.returncode, 0, f"{res.stderr}\n{res.stdout}")
|
|
pkgbuild = (capture / "PKGBUILD").read_text()
|
|
makepkg_conf = (capture / "makepkg.conf").read_text()
|
|
|
|
# Check safe assignments in makepkg.conf
|
|
self.assertIn("CARCH=x86_64", makepkg_conf)
|
|
self.assertIn("CHOST=x86_64-unknown-linux-gnu", makepkg_conf)
|
|
|
|
# Check safe assignments in PKGBUILD
|
|
self.assertIn("pkgver=1.2.3", pkgbuild)
|
|
self.assertIn("pkgrel=2", pkgbuild)
|
|
self.assertIn("arch=(x86_64)", pkgbuild)
|
|
self.assertTrue(
|
|
"license=(MIT\\ OR\\ Apache-2.0)" in pkgbuild or "license=('MIT OR Apache-2.0')" in pkgbuild,
|
|
f"Expected preserved SPDX expression in pkgbuild: {pkgbuild}",
|
|
)
|
|
self.assertIn("provides=(myapp=1.2.3-2)", pkgbuild)
|
|
|
|
def test_prerelease_version_from_the_tarball_name(self):
|
|
# release.yml names the tarball after the resolved version, so a prerelease such as a
|
|
# 0.2.0-rc.1 test run reaches here with a hyphen in it. Arch pkgver has no hyphens; each
|
|
# form below still sorts before its release in pacman.
|
|
for version, pkgver in (("0.2.0-rc.1", "0.2.0rc.1"), ("1.2.3-1", "1.2.3pre1"), ("1.2.3--1", "1.2.3pre1")):
|
|
with self.subTest(version=version), tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
tarball = self._create_fixture_tarball(tmp_path, filename=f"myapp-{version}-linux-x86_64.tar.gz")
|
|
tools = self._setup_mock_tools(tmp_path)
|
|
# Extract for real: an unmatched name falls back to the archive's executable.
|
|
(tools / "tar").unlink()
|
|
capture = tmp_path / "capture"
|
|
capture.mkdir()
|
|
res = subprocess.run(
|
|
[str(ARCH_SCRIPT), str(tarball), "x86_64", str(tmp_path / "out")],
|
|
capture_output=True,
|
|
text=True,
|
|
env={**os.environ, "PATH": f"{tools}:{os.environ['PATH']}", "MOCK_CAPTURE": str(capture)},
|
|
)
|
|
self.assertEqual(res.returncode, 0, f"{res.stderr}\n{res.stdout}")
|
|
pkgbuild = (capture / "PKGBUILD").read_text()
|
|
self.assertIn(f"pkgver={pkgver}\n", pkgbuild)
|
|
self.assertIn("pkgrel=1\n", pkgbuild)
|
|
self.assertIn(f"provides=(myapp={pkgver}-1)", pkgbuild)
|
|
self.assertEqual([p.name for p in (tmp_path / "out").iterdir()],
|
|
[f"myapp-{version}-linux-x86_64.pkg.tar.zst"])
|
|
|
|
def test_pkgbuild_depends_on_the_x11_keyboard_library(self):
|
|
"""The PKGBUILD depends on libxkbcommon-x11 as well as libxkbcommon.
|
|
|
|
winit's X11 backend loads libxkbcommon-x11 at startup and panics without it, and Arch
|
|
ships it apart from libxkbcommon: a package without it could not open its window on
|
|
X11 or XWayland.
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
tarball = self._create_fixture_tarball(tmp_path)
|
|
tools = self._setup_mock_tools(tmp_path)
|
|
capture = tmp_path / "capture"
|
|
capture.mkdir()
|
|
res = subprocess.run(
|
|
[str(ARCH_SCRIPT), str(tarball), "x86_64", str(tmp_path / "out")],
|
|
capture_output=True,
|
|
text=True,
|
|
env={**os.environ, "PATH": f"{tools}:{os.environ['PATH']}", "MOCK_CAPTURE": str(capture)},
|
|
)
|
|
self.assertEqual(res.returncode, 0, f"{res.stderr}\n{res.stdout}")
|
|
depends = next(
|
|
line for line in (capture / "PKGBUILD").read_text().splitlines() if line.startswith("depends=(")
|
|
)
|
|
for package in ("'libxkbcommon'", "'libxkbcommon-x11'"):
|
|
self.assertIn(package, depends)
|
|
|
|
def test_package_function_rejects_symlinked_license_dir(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
tarball = self._create_fixture_tarball(tmp_path)
|
|
# Use mock makepkg that sources PKGBUILD and executes package() with a symlinked lic_dir
|
|
makepkg_script = """#!/bin/bash
|
|
set -e
|
|
source PKGBUILD
|
|
# Test 1: symlinked licenses directory
|
|
pkgdir="$(mktemp -d /tmp/pkgdir.XXXXXX)"
|
|
trap 'rm -rf "$pkgdir"' EXIT
|
|
mkdir -p "$pkgdir/usr/share"
|
|
mkdir -p /tmp/external_dir
|
|
ln -s /tmp/external_dir "$pkgdir/usr/share/licenses"
|
|
if package; then
|
|
echo "FAILED: package() succeeded despite symlinked licenses dir"
|
|
exit 1
|
|
fi
|
|
echo "SUCCESS: package() rejected symlinked licenses dir"
|
|
touch "myapp-1.2.3-1-x86_64.pkg.tar.zst"
|
|
exit 0
|
|
"""
|
|
tools = self._setup_mock_tools(tmp_path, makepkg_body=makepkg_script)
|
|
res = subprocess.run(
|
|
[str(ARCH_SCRIPT), str(tarball), "x86_64", str(tmp_path / "out")],
|
|
capture_output=True,
|
|
text=True,
|
|
env={**os.environ, "PATH": f"{tools}:{os.environ['PATH']}"},
|
|
)
|
|
self.assertEqual(res.returncode, 0, f"{res.stderr}\n{res.stdout}")
|
|
self.assertIn("SUCCESS: package() rejected symlinked licenses dir", res.stdout)
|
|
|
|
def _metainfo_tarball(self, tmp_path, add_metainfo):
|
|
"""A release tarball whose share/metainfo entry is written by add_metainfo(tar, arcname)."""
|
|
tarball = self._create_fixture_tarball(tmp_path)
|
|
arcname = "share/metainfo/ai.storyteller.myapp.metainfo.xml"
|
|
with tarfile.open(tarball, "r:gz") as src:
|
|
members = [(m, src.extractfile(m).read() if m.isfile() else None) for m in src.getmembers()]
|
|
with tarfile.open(tarball, "w:gz") as tar:
|
|
for member, data in members:
|
|
tar.addfile(member, io.BytesIO(data) if data is not None else None)
|
|
add_metainfo(tar, arcname)
|
|
return tarball
|
|
|
|
def _run_with_real_tar(self, tmp_path, tarball):
|
|
# The shared mock tools replace tar with a no-op; these tests need the archive extracted.
|
|
tools = self._setup_mock_tools(tmp_path)
|
|
(tools / "tar").unlink()
|
|
capture = tmp_path / "capture"
|
|
capture.mkdir(exist_ok=True)
|
|
return capture, subprocess.run(
|
|
[str(ARCH_SCRIPT), str(tarball), "x86_64", str(tmp_path / "out")],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
env={**os.environ, "PATH": f"{tools}:{os.environ['PATH']}", "MOCK_CAPTURE": str(capture)},
|
|
)
|
|
|
|
def test_metainfo_summary_and_homepage_are_read(self):
|
|
def add(tar, arcname):
|
|
data = (b'<component><summary>Edits & paints</summary>'
|
|
b'<url type="homepage">https://example.com/myapp</url></component>\n')
|
|
info = tarfile.TarInfo(arcname)
|
|
info.size = len(data)
|
|
tar.addfile(info, io.BytesIO(data))
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
capture, res = self._run_with_real_tar(tmp_path, self._metainfo_tarball(tmp_path, add))
|
|
self.assertEqual(res.returncode, 0, f"{res.stderr}\n{res.stdout}")
|
|
pkgbuild = (capture / "PKGBUILD").read_text()
|
|
self.assertIn("pkgdesc=Edits\\ \\&\\ paints", pkgbuild)
|
|
self.assertIn("url=https://example.com/myapp", pkgbuild)
|
|
|
|
def test_metainfo_that_is_not_a_regular_file_is_refused(self):
|
|
"""A metainfo entry that links to an endless stream, or is a FIFO, is refused before it is read."""
|
|
def symlink_to_zero(tar, arcname):
|
|
info = tarfile.TarInfo(arcname)
|
|
info.type = tarfile.SYMTYPE
|
|
info.linkname = "/dev/zero"
|
|
tar.addfile(info)
|
|
|
|
def fifo(tar, arcname):
|
|
info = tarfile.TarInfo(arcname)
|
|
info.type = tarfile.FIFOTYPE
|
|
tar.addfile(info)
|
|
|
|
for name, add in (("symlink", symlink_to_zero), ("fifo", fifo)):
|
|
with self.subTest(kind=name), tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
_, res = self._run_with_real_tar(tmp_path, self._metainfo_tarball(tmp_path, add))
|
|
self.assertNotEqual(res.returncode, 0)
|
|
self.assertIn("refusing metainfo", res.stderr)
|
|
|
|
def test_metainfo_read_is_bounded(self):
|
|
"""Only a bounded prefix of a large metainfo file is parsed."""
|
|
def add(tar, arcname):
|
|
data = b"x" * (2 * 1024 * 1024) + b"<summary>past the limit</summary>\n"
|
|
info = tarfile.TarInfo(arcname)
|
|
info.size = len(data)
|
|
tar.addfile(info, io.BytesIO(data))
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
capture, res = self._run_with_real_tar(tmp_path, self._metainfo_tarball(tmp_path, add))
|
|
self.assertEqual(res.returncode, 0, f"{res.stderr}\n{res.stdout}")
|
|
self.assertIn("pkgdesc=myapp", (capture / "PKGBUILD").read_text())
|
|
|
|
@unittest.skipUnless(
|
|
shutil.which("makepkg") and shutil.which("bsdtar") and shutil.which("zstd"),
|
|
"makepkg/bsdtar/zstd not installed",
|
|
)
|
|
def test_arch_package_build_end_to_end(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
tar_root = tmp_path / "myapp-1.2.3-linux-x86_64"
|
|
(tar_root / "bin").mkdir(parents=True)
|
|
(tar_root / "share/applications").mkdir(parents=True)
|
|
(tar_root / "bin/myapp").write_text("#!/bin/sh\necho hello\n")
|
|
os.chmod(tar_root / "bin/myapp", 0o755)
|
|
(tar_root / "share/applications/com.example.myapp.desktop").write_text(
|
|
"[Desktop Entry]\nName=MyApp\nExec=myapp\nType=Application\n"
|
|
)
|
|
tarball = tmp_path / "myapp-1.2.3-linux-x86_64.tar.gz"
|
|
with tarfile.open(tarball, "w:gz") as tar:
|
|
tar.add(tar_root, arcname=tar_root.name)
|
|
out_dir = tmp_path / "dist"
|
|
res = subprocess.run(
|
|
[str(ARCH_SCRIPT), str(tarball), "x86_64", str(out_dir)],
|
|
capture_output=True,
|
|
text=True,
|
|
env={**os.environ, "ARCH_LICENSE": "MIT", "PKGREL": "1"},
|
|
)
|
|
self.assertEqual(res.returncode, 0, f"arch-package.sh failed: {res.stderr}\n{res.stdout}")
|
|
pkg_file = out_dir / "myapp-1.2.3-linux-x86_64.pkg.tar.zst"
|
|
self.assertTrue(pkg_file.exists())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|