diff --git a/.github/workflows/arch-package.yml b/.github/workflows/arch-package.yml new file mode 100644 index 00000000000..e7e20ad5116 --- /dev/null +++ b/.github/workflows/arch-package.yml @@ -0,0 +1,30 @@ +name: Arch package + +on: + pull_request: + paths: + - "scripts/build-linux-arch-package*" + - ".github/workflows/arch-package.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Arch package versions + runs-on: blacksmith-2vcpu-ubuntu-2404 + container: archlinux:base-devel@sha256:68bfc3b0d277b08a99101dc9b94aaa03e5ae70cf1b4fb965c03b2b87b915760d + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - name: Install Node.js + run: pacman -Syu --noconfirm --needed nodejs + - name: Test package ordering and payloads + shell: bash + run: | + set -euo pipefail + useradd --create-home cap-package-test + runuser -u cap-package-test -- node --test --test-reporter=tap scripts/build-linux-arch-package.test.mjs | tee /tmp/arch-package-tests.tap + grep -q '^# skipped 0$' /tmp/arch-package-tests.tap diff --git a/packaging/linux/README.md b/packaging/linux/README.md index 138bcc76b43..cb246aa71a8 100644 --- a/packaging/linux/README.md +++ b/packaging/linux/README.md @@ -33,10 +33,12 @@ This removes any old signature. The host must provide its Wayland client and Pip Run the package builder as an ordinary user in an Arch environment with `makepkg` and `bsdtar`: ```sh -bash scripts/build-linux-arch-package.sh Cap_0.6.0_amd64.deb ./arch-packages -sudo pacman -U ./arch-packages/cap-bin-0.6.0-1-x86_64.pkg.tar.zst +bash scripts/build-linux-arch-package.sh Cap_0.6.2_amd64.deb ./arch-packages +sudo pacman -U ./arch-packages/cap-bin-0.6.2-1-x86_64.pkg.tar.zst ``` +The output filename omits the epoch. Package metadata retains version `1:0.6.2-1` in this example, which `pacman` uses for upgrade ordering. + The builder reuses the exact DEB payload, changes the package-format marker, and declares Arch dependencies. It does not strip or rebuild the executables. Release CI uses a pinned Arch container without network access during packaging. On Omarchy, screen capture uses the installed Hyprland desktop portal and PipeWire. Package installation alone does not verify recording, playback, export, or device access; these require tests in the target desktop session. diff --git a/scripts/build-linux-arch-package.sh b/scripts/build-linux-arch-package.sh index ce972e328fd..4d1e1e10500 100644 --- a/scripts/build-linux-arch-package.sh +++ b/scripts/build-linux-arch-package.sh @@ -43,15 +43,17 @@ cp "$deb" "$work/Cap.deb" checksum="$(sha256sum "$work/Cap.deb" | cut -d ' ' -f 1)" cat > "$work/PKGBUILD" < + spawnSync("sh", ["-c", `command -v ${command}`]).status === 0; +const canPackage = + process.platform === "linux" && + process.getuid() !== 0 && + ["makepkg", "vercmp", "bsdtar", "ar", "fakeroot", "zstd"].every(available); + +test( + "Arch prereleases upgrade to stable and supersede existing packages", + { skip: !canPackage }, + async (t) => { + const root = await mkdtemp(path.join(tmpdir(), "cap-arch-packages-")); + t.after(() => rm(root, { recursive: true, force: true })); + const temporary = path.join(root, "tmp"); + await mkdir(temporary); + const config = path.join(root, "makepkg.conf"); + await writeFile( + config, + [ + await readFile("/etc/makepkg.conf", "utf8"), + "BUILDENV=(!distcc !color !ccache !check !sign)", + 'PACKAGER="Synthetic fixture "', + "OPTIONS=(!strip !docs !libtool !staticlibs emptydirs !zipman !purge !debug !lto)", + "COMPRESSZST=(zstd -c -T1 -)", + "PKGEXT='.pkg.tar.zst'", + "", + ].join("\n"), + ); + const env = { + ...process.env, + TMPDIR: temporary, + MAKEPKG_CONF: config, + CARCH: "x86_64", + }; + function run(command, args, cwd = root) { + const result = spawnSync(command, args, { + cwd, + env, + encoding: "utf8", + timeout: 30_000, + }); + assert.ifError(result.error); + assert.equal( + result.status, + 0, + `${command}: ${result.stdout}\n${result.stderr}`, + ); + return result.stdout.trim(); + } + const versions = [ + "0.6.2-nightly.202610210300", + "0.6.2-nightly.202610220300", + "0.6.2-rc.1", + "0.6.2-1", + "0.6.2", + ]; + const packages = []; + for (const version of versions) { + const directory = path.join(root, version); + const control = path.join(directory, "control"); + const payload = path.join(directory, "payload"); + const output = path.join(directory, "output"); + await mkdir(control, { recursive: true }); + await mkdir(path.join(payload, "usr/bin"), { recursive: true }); + await mkdir(path.join(payload, "usr/lib/cap"), { recursive: true }); + await mkdir(output); + await writeFile(path.join(directory, "debian-binary"), "2.0\n"); + await writeFile( + path.join(control, "control"), + `Package: cap\nVersion: ${version}\nArchitecture: amd64\nMaintainer: Synthetic fixture \nDescription: Synthetic packaging fixture\n`, + ); + const binaryContents = + "#!/bin/sh\nprintf '%s\\n' 'synthetic Cap binary'\n"; + for (const binary of ["Cap", "cap-cli", "cap-muxer"]) { + await writeFile(path.join(payload, "usr/bin", binary), binaryContents, { + mode: 0o755, + }); + } + await writeFile( + path.join(payload, "usr/lib/cap/package-format"), + "deb\n", + ); + run("bsdtar", [ + "-czf", + path.join(directory, "control.tar.gz"), + "-C", + control, + ".", + ]); + run("bsdtar", [ + "-czf", + path.join(directory, "data.tar.gz"), + "-C", + payload, + ".", + ]); + const deb = path.join(directory, "Cap.deb"); + run( + "ar", + ["rcD", deb, "debian-binary", "control.tar.gz", "data.tar.gz"], + directory, + ); + run("bash", [script, deb, output]); + const files = (await readdir(output)).filter((file) => + file.endsWith(".pkg.tar.zst"), + ); + assert.equal(files.length, 1); + const artifact = path.join(output, files[0]); + const metadata = run("bsdtar", ["-xOf", artifact, ".PKGINFO"]); + const pkgver = /^pkgver = (.+)$/m.exec(metadata)?.[1]; + assert.ok(pkgver); + assert.match(pkgver, /^1:/); + assert.equal( + files[0], + `cap-bin-${pkgver.replace(/^1:/, "")}-x86_64.pkg.tar.zst`, + ); + packages.push(pkgver); + assert.match(metadata, /^arch = x86_64$/m); + assert.ok( + metadata.includes(`provides = cap=${pkgver.replace(/-1$/, "")}\n`), + ); + assert.equal( + run("bsdtar", ["-xOf", artifact, "usr/lib/cap/package-format"]), + "arch", + ); + const extracted = path.join(directory, "extracted"); + await mkdir(extracted); + run("bsdtar", ["-xf", artifact, "-C", extracted]); + for (const binary of ["Cap", "cap-cli", "cap-muxer"]) { + assert.equal( + await readFile(path.join(extracted, "usr/bin", binary), "utf8"), + binaryContents, + ); + assert.equal( + run(path.join(extracted, "usr/bin", binary), []), + "synthetic Cap binary", + ); + } + } + for (const prerelease of packages.slice(0, -1)) { + assert.equal(run("vercmp", [prerelease, packages.at(-1)]), "-1"); + } + assert.equal(run("vercmp", [packages[1], packages[0]]), "1"); + assert.equal( + run("vercmp", [packages[1], "0.6.2_nightly.202610210300-1"]), + "1", + ); + assert.equal(run("vercmp", [packages.at(-1), "0.6.2-1"]), "1"); + }, +);