diff --git a/pkgbuilds/t3code-bin/.omarchy/upstream.sh b/pkgbuilds/t3code-bin/.omarchy/upstream.sh index 6bc010c..63c436d 100755 --- a/pkgbuilds/t3code-bin/.omarchy/upstream.sh +++ b/pkgbuilds/t3code-bin/.omarchy/upstream.sh @@ -1,8 +1,9 @@ #!/bin/bash # T3 Code publishes electron-builder's update feed beside every release, so the -# newest version costs one small request. The feed's checksum is a base64 +# newest version costs one small request. Each feed's checksum is a base64 # SHA-512 and makepkg wants hex SHA-256, so a release that is actually new still -# has to be downloaded once to hash -- hence the version check before the fetch. +# has to be downloaded once per architecture to hash -- hence the version check +# before the fetch. set -euo pipefail FEED_URL="https://github.com/pingdotgg/t3code/releases/latest/download/latest-linux.yml" @@ -23,7 +24,7 @@ if [[ -n "$current" ]] && [[ "$(vercmp "$version" "$current")" -le 0 ]]; then exit 0 fi -# The PKGBUILD builds one fixed asset name, so a feed naming anything else -- +# The PKGBUILD builds fixed asset names, so a feed naming anything else -- # a rename, or an arm64 build reaching the Linux feed first -- has to stop the # sync rather than pin that file's checksum to a URL nobody will fetch. expected="T3-Code-${version}-x86_64.AppImage" @@ -32,7 +33,18 @@ if [[ "$asset" != "$expected" ]]; then exit 1 fi -sha256=$(curl -fsSL "$RELEASE_URL/v${version}/${asset}" | sha256sum | cut -d' ' -f1) +# Pin the ARM feed to the same release, so a partially published release or a +# latest-release change cannot mix versions between architectures. +arm_feed=$(curl -fsSL "$RELEASE_URL/v${version}/latest-linux-arm64.yml") +arm_version=$(awk '/^version:/ { print $2; exit }' <<<"$arm_feed" | tr -d '"'\''') +arm_asset=$(awk '/^path:/ { print $2; exit }' <<<"$arm_feed" | tr -d '"'\''') +if [[ "$arm_version" != "$version" || "$arm_asset" != "T3-Code-${version}-arm64.AppImage" ]]; then + echo "Upstream ARM feed does not match T3-Code-${version}-arm64.AppImage" >&2 + exit 1 +fi -jq -n --arg pkgver "$version" --arg sha256 "$sha256" \ - '{pkgver: $pkgver, sha256sums: {x86_64: [$sha256]}}' +sha256=$(curl -fsSL "$RELEASE_URL/v${version}/${asset}" | sha256sum | cut -d' ' -f1) +arm_sha256=$(curl -fsSL "$RELEASE_URL/v${version}/${arm_asset}" | sha256sum | cut -d' ' -f1) + +jq -n --arg pkgver "$version" --arg sha256 "$sha256" --arg arm_sha256 "$arm_sha256" \ + '{pkgver: $pkgver, sha256sums: {x86_64: [$sha256], aarch64: [$arm_sha256]}}' diff --git a/pkgbuilds/t3code-bin/PKGBUILD b/pkgbuilds/t3code-bin/PKGBUILD index fe90494..235022d 100644 --- a/pkgbuilds/t3code-bin/PKGBUILD +++ b/pkgbuilds/t3code-bin/PKGBUILD @@ -1,18 +1,19 @@ # Maintainer: David Heinemeier Hansson -# T3 Code ships Linux as an AppImage and nothing else, so Omarchy unpacks it and +# T3 Code ships its Linux desktop as an AppImage, so Omarchy unpacks it and # keeps only the Electron tree. AppRun, the compatibility libraries bundled for # distributions that do not ship their own, and the AppImage's icon shims are all -# dead weight here. .omarchy/upstream.sh rewrites the version and checksum below -# from the release feed the app updates itself from. +# dead weight here. .omarchy/upstream.sh rewrites the version and checksums below +# from the release feeds the app updates itself from. pkgname=t3code-bin pkgver=0.0.42 -pkgrel=1 +pkgrel=2 pkgdesc="Open-source control plane for coding agents" -arch=('x86_64') +arch=('x86_64' 'aarch64') url="https://t3.codes" license=('MIT') +makedepends=('7zip') depends=( 'alsa-lib' @@ -54,19 +55,28 @@ provides=("t3code=${pkgver}") conflicts=('t3code') options=('!debug' '!strip') -_appimage="T3-Code-${pkgver}-x86_64.AppImage" +_appimage_x86_64="T3-Code-${pkgver}-x86_64.AppImage" +_appimage_aarch64="T3-Code-${pkgver}-arm64.AppImage" source=('t3code-launcher.sh' 't3-launcher.sh' 'LICENSE') -source_x86_64=("${_appimage}::https://github.com/pingdotgg/t3code/releases/download/v${pkgver}/${_appimage}") -noextract=("${_appimage}") +source_x86_64=("https://github.com/pingdotgg/t3code/releases/download/v${pkgver}/${_appimage_x86_64}") +source_aarch64=("https://github.com/pingdotgg/t3code/releases/download/v${pkgver}/${_appimage_aarch64}") +noextract=("${_appimage_x86_64}" "${_appimage_aarch64}") sha256sums=('cb905ff341372ef2ef6e402cf485959f8bd1df8f0efebee4cda1afdd5e6abc0a' 'c5b3f2a9f0b14b12cfd973b79319f0f018b7ae49a1d43d8ca346100c3f7de28f' '935d8f2af0c703f9c39517ee57cc4930b19d02d533be930b63f0e82f93614b43') sha256sums_x86_64=('8dc1fccdabc2ed3a59a3944cc772ef11931b9351401c0963ed305d5f96e3cdf4') +sha256sums_aarch64=('c256d872d358e9f2c91328b0154c6311fa2eb16386ef6f788fcda12d73cf2836') prepare() { - chmod +x "${srcdir}/${_appimage}" + local _appimage + case "$CARCH" in + x86_64) _appimage="${_appimage_x86_64}" ;; + aarch64) _appimage="${_appimage_aarch64}" ;; + esac rm -rf "${srcdir}/squashfs-root" - "${srcdir}/${_appimage}" --appimage-extract >/dev/null + # Extract without executing the runtime: AppImage's ELF magic does not match + # QEMU's binfmt registration when building ARM packages on an x86_64 host. + 7z x "${srcdir}/${_appimage}" -o"${srcdir}/squashfs-root" >/dev/null } package() { diff --git a/tests/upstream-watch.py b/tests/upstream-watch.py index 856a928..bb9c8dc 100644 --- a/tests/upstream-watch.py +++ b/tests/upstream-watch.py @@ -330,5 +330,71 @@ os.execv(os.environ['REAL_GIT'], ['git', *args]) self.assertEqual(metadata_file.read_bytes(), original) +class T3CodeHookTest(unittest.TestCase): + """Keep both desktop architectures on the same complete upstream release.""" + + def setUp(self): + work = tempfile.TemporaryDirectory() + self.addCleanup(work.cleanup) + self.root = Path(work.name) + self.recipe = self.root / "PKGBUILD" + self.recipe.write_text("pkgver=0.0.41\n") + self.feed = self.root / "latest-linux.yml" + self.feed.write_text("version: 0.0.42\npath: T3-Code-0.0.42-x86_64.AppImage\n") + self.arm_feed = self.root / "latest-linux-arm64.yml" + self.arm_feed.write_text("version: 0.0.42\npath: T3-Code-0.0.42-arm64.AppImage\n") + for arch in ("x86_64", "arm64"): + (self.root / f"T3-Code-0.0.42-{arch}.AppImage").write_text(arch) + # Serve only fixture assets, and record the requested release URLs. + curl = self.root / "curl" + curl.write_text('#!/bin/bash\nurl="${@: -1}"\nprintf "%s\\n" "$url" >> requests\ncat "${url##*/}"\n') + curl.chmod(0o755) + self.env = dict(os.environ, PATH=f"{self.root}:{os.environ['PATH']}") + + def run_hook(self): + return subprocess.run( + ['bash', str(ROOT / 'pkgbuilds/t3code-bin/.omarchy/upstream.sh')], + cwd=self.root, env=self.env, text=True, capture_output=True, + ) + + def test_hashes_both_architectures_from_one_release(self): + result = self.run_hook() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), { + 'pkgver': '0.0.42', + 'sha256sums': { + arch: [w.hash_file(self.root / f'T3-Code-0.0.42-{asset_arch}.AppImage', 'sha256')] + for arch, asset_arch in [('x86_64', 'x86_64'), ('aarch64', 'arm64')] + }, + }) + self.assertIn('/download/v0.0.42/latest-linux-arm64.yml', (self.root / 'requests').read_text()) + + def test_current_version_does_not_download_assets(self): + self.recipe.write_text('pkgver=0.0.42\n') + result = self.run_hook() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), {}) + self.assertEqual(len((self.root / 'requests').read_text().splitlines()), 1) + + def test_incomplete_or_mismatched_arm_release_reports_no_update(self): + for bad_feed in ('', 'version: 0.0.43\npath: T3-Code-0.0.42-arm64.AppImage\n', + 'version: 0.0.42\npath: renamed.AppImage\n'): + with self.subTest(feed=bad_feed): + self.arm_feed.write_text(bad_feed) + result = self.run_hook() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, '') + self.arm_feed.unlink() + result = self.run_hook() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, '') + + def test_missing_arm_asset_reports_no_update(self): + (self.root / 'T3-Code-0.0.42-arm64.AppImage').unlink() + result = self.run_hook() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, '') + + if __name__ == '__main__': unittest.main(verbosity=2)