Files
thoughtsync/desktop/packaging/arch/package-prebuilt.sh
T
bvandeusenandClaude Opus 5 c5044339a1
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Canceled after 13s
CI & Build / integration (push) Canceled after 13s
CI & Build / Build & push image (push) Canceled after 0s
Android / Kotlin + Rust (APK) (push) Failing after 14s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m19s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m24s
Desktop (Tauri) / Update manifest (push) Failing after 4s
versioning: each artifact derives from its own files, with the clock picked per value
Step 4 of M314. `desktop/packaging/build-version.sh` was one generator feeding
the desktop bundles AND the Android APK off `GITHUB_RUN_NUMBER`, so a
Kotlin-only commit re-versioned the desktop and a Rust-only commit
re-versioned the phone. Note 3127 §3 cites this repo as its example of that
failure. It is replaced by `packaging/version.sh` — one definition of HOW to
derive, three file sets, and the sets in one place.

Lives at the repo root rather than under desktop/, because it serves three
artifacts now and a shared thing filed under one consumer ends up owned by it.

## Two values, and the clock chosen per value (§2)

  desktop  key      1.0.<minutes since 2020-01-01>   commit time
  desktop  display  2026.08.28.0900                  commit time  (#3181 shows it)
  android  versionName                               commit time
  android  versionCode  <minutes since 2020>         BUILD time
  server   version  2026.08.28.0900                  commit time, no ordering key

Every human-readable version in the repo is now one shape. The two exceptions
are not version names at all — they are bare monotonic integers a comparator
reads and nobody quotes.

The desktop needs a separate key because Tauri parses `latest.json` with the
semver crate and `2026.08.28.0900` fails it twice (four segments, and `08` is a
leading zero). `1.0.` and not `0.0.`: the minor has to clear the installed
`0.2.466` line or every dev user is stranded on "up to date" permanently.

Android's code comes from BUILD time while the desktop's key comes from COMMIT
time, deliberately. Android hard-fails a downgrade with
INSTALL_FAILED_VERSION_DOWNGRADE and leaves a channel you cannot get out of, so
its key must be monotonic by construction; the desktop merely declines to offer
an update, which a guard can catch.

## The bug this found in itself

The shallow-clone guard `exit 1`-ed inside a function called as `$(...)` —
which ends the SUBSHELL, not the script. `display` still failed, but only
because `date` then choked on the empty string. `key` printed the error to
stderr, emitted `1.0.-26297280`, and exited ZERO.

That is precisely the failure the guard exists to prevent: a too-low version on
a green lane, and too-low is the direction you cannot recover from. It resolves
into a global in the parent shell now. The test is parametrized over both
requests, because one path was covered and the other was broken in exactly the
way the covered one was meant to rule out.

## Also

`fetch-depth: 0` on every job that derives — four of them, and only ci.yml's
gate had it. Depth-1 is silently wrong rather than loudly broken (§6.1).

The file sets include each artifact's BUILD RECIPE (its workflow, and
`packaging/`). A workflow file is not shipped, but change a Gradle flag and the
bytes change while the source does not — and once step 6 skips a build whose
version already exists, that serves the OLD artifact on a green run.

The base images are deliberately NOT resolved at derive time: that is an
external lookup, which §7's corollary forbids. `Dockerfile` is already in the
server's set, so pinning `FROM` by digest in step 6 puts the base inside the set
for free.

`build-version.sh` is deleted, its last consumer (the pacman packager) moved
over, and the one finding worth keeping out of its header — why not a `-dev.N`
prerelease — is preserved in the successor.

#3144

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 20:51:39 -04:00

170 lines
8.0 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Build a PREBUILT Arch package (.pkg.tar.zst) from the binary the desktop CI job
# already compiled — no makepkg, no Arch container, no second Rust build.
#
# WHY prebuilt: the source PKGBUILD this replaces required every user to install
# rust + nodejs + npm and compile for minutes before they could open the app.
# Distribution means shipping a package people can just install, so the one-command
# installer (install.sh) can `pacman -U` a real native package on Arch/CachyOS
# instead of falling back to the AppImage.
#
# WHY it's safe to package a Debian-compiled binary for Arch: the binary bundles
# nothing. It resolves libwebkit2gtk-4.1.so.0 / libgtk-3.so.0 / libsoup-3.0.so.0 by
# SONAME at runtime, and those sonames are identical on both distros; SQLite is
# compiled in (rusqlite "bundled"). glibc symbol versioning is forward-compatible,
# and we build on Debian's older glibc and run on Arch's newer one — the safe
# direction. Using the host's graphics stack is also exactly what keeps issue 2021
# (bundled-lib EGL_BAD_PARAMETER black window) fixed.
#
# A .pkg.tar.* is just a tar whose FIRST entry is .PKGINFO, so we assemble it
# directly rather than nesting an Arch container over the docker socket.
#
# CI cannot verify this end-to-end: the runner is Debian and has no pacman. The
# step logs .PKGINFO and the full file listing so the package is auditable from the
# build log; `pacman -U` on a real Arch box is the final proof.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
SRC_TAURI="$REPO_ROOT/desktop/src-tauri"
BINARY="$REPO_ROOT/target/release/thoughtsync"
OUT_DIR="${1:-$REPO_ROOT/target/release/bundle/arch}"
PKGNAME="thoughtsync"
# The name this package used to ship under. pacman needs both to retire it: without
# them a `pacman -U` of the renamed package installs ALONGSIDE the old one, and two
# packages both own /usr/bin/thoughtsync (issue 2075).
REPLACES=(thoughtsync-desktop)
PKGREL=1
PKGDESC="ThoughtSync desktop — local-first Keep-style thought capture"
URL="https://git.fabledsword.com/bvandeusen/thoughtsync"
LICENSE="MIT"
PACKAGER="ThoughtSync CI <noreply@fabledsword.com>"
# Runtime deps. Deliberately minimal and grounded in what the binary actually
# links: tauri is built with features=[] (no tray -> no libayatana-appindicator)
# and rusqlite is "bundled" (no sqlite package). Everything else — glib2, cairo,
# pango, gdk-pixbuf2, libsoup3 — arrives transitively via these two, so listing
# them would just be noise that can drift out of date.
DEPENDS=(webkit2gtk-4.1 gtk3)
[ -f "$BINARY" ] || {
echo "ERROR: no binary at $BINARY — run the tauri/cargo build first." >&2
exit 1
}
# Single source of truth for the version: the SAME helper the bundle build uses.
#
# It used to read tauri.conf.json directly, which was right until dev builds began
# overriding the version on the command line (M10.9) — the file still says 0.1.0, so
# the pacman package came out stamped 0.1.0 around a binary reporting 0.1.132. A
# package that lies about its version is exactly what makes a later "which build is
# this?" question unanswerable.
# `|| true` so a miss falls through to the explicit error below rather than
# aborting on pipefail with no explanation.
# The ORDERING KEY: pacman compares this, and it must match the filename the
# bundle build produced (write-manifest.sh selects on it).
PKGVER="$(sh "$SCRIPT_DIR/../../../packaging/version.sh" key desktop || true)"
[ -n "$PKGVER" ] || { echo "ERROR: could not determine the build version" >&2; exit 1; }
# Reproducible-ish: prefer the commit date over "now" so rebuilding the same
# commit produces the same builddate.
BUILDDATE="$(git -C "$REPO_ROOT" log -1 --format=%ct 2>/dev/null || date +%s)"
echo "==> Packaging $PKGNAME $PKGVER-$PKGREL (x86_64) for pacman"
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT INT TERM
# --- lay out the filesystem tree --------------------------------------------
# /usr/bin/thoughtsync — the same command name the .deb installs and the AppImage
# installer symlinks into ~/.local/bin, so it's identical whichever way the app
# arrived. The binary already carries this name (Cargo `[[bin]]`), which is also
# what the .desktop entry's StartupWMClass has to match.
install -Dm755 "$BINARY" "$STAGE/usr/bin/thoughtsync"
install -Dm644 "$SCRIPT_DIR/thoughtsync.desktop" \
"$STAGE/usr/share/applications/thoughtsync.desktop"
# Themed icons, so the menu entry's `Icon=thoughtsync` resolves at every size the
# desktop asks for; pixmaps is the legacy fallback for older launchers.
install -Dm644 "$SRC_TAURI/icons/32x32.png" \
"$STAGE/usr/share/icons/hicolor/32x32/apps/thoughtsync.png"
install -Dm644 "$SRC_TAURI/icons/128x128.png" \
"$STAGE/usr/share/icons/hicolor/128x128/apps/thoughtsync.png"
install -Dm644 "$SRC_TAURI/icons/128x128@2x.png" \
"$STAGE/usr/share/icons/hicolor/256x256/apps/thoughtsync.png"
install -Dm644 "$SRC_TAURI/icons/icon.png" \
"$STAGE/usr/share/icons/hicolor/512x512/apps/thoughtsync.png"
install -Dm644 "$SRC_TAURI/icons/icon.png" "$STAGE/usr/share/pixmaps/thoughtsync.png"
# Installed size in KiB, as pacman reports it.
INSTALLED_SIZE="$(du -sb "$STAGE" | cut -f1)"
# --- .PKGINFO ---------------------------------------------------------------
{
echo "# Generated by desktop/packaging/arch/package-prebuilt.sh"
echo "pkgname = $PKGNAME"
echo "pkgbase = $PKGNAME"
echo "pkgver = $PKGVER-$PKGREL"
echo "pkgdesc = $PKGDESC"
echo "url = $URL"
echo "builddate = $BUILDDATE"
echo "packager = $PACKAGER"
echo "size = $INSTALLED_SIZE"
echo "arch = x86_64"
echo "license = $LICENSE"
for d in "${DEPENDS[@]}"; do echo "depend = $d"; done
# conflict + replaces together: `conflict` is what makes pacman remove the old
# package rather than refuse the transaction, `replaces` is what makes an upgrade
# pick this one up under its new name.
for r in "${REPLACES[@]}"; do echo "conflict = $r"; echo "replaces = $r"; done
} >"$STAGE/.PKGINFO"
# --- .MTREE (optional) ------------------------------------------------------
# pacman uses .MTREE for `pacman -Qkk` file verification; it is NOT required to
# install, and generating it needs bsdtar (libarchive-tools), which the Debian CI
# image may not carry. Emit it when we can, skip loudly when we can't, rather
# than adding an apt install to the job (rule 5: the image is the toolchain).
MTREE_ENTRY=()
if command -v bsdtar >/dev/null 2>&1; then
( cd "$STAGE" && LANG=C bsdtar -czf .MTREE --format=mtree \
--options='!all,use-set,type,uid,gid,mode,time,size,md5,sha256,link' \
.PKGINFO usr )
MTREE_ENTRY=(.MTREE)
echo " .MTREE generated (bsdtar present)"
else
echo " NOTE: bsdtar absent — packaging without .MTREE."
echo " pacman installs this fine; only 'pacman -Qkk' file verification is degraded."
fi
# --- compress ---------------------------------------------------------------
# pacman reads whatever libarchive can decompress, so degrade gracefully instead
# of hard-requiring zstd in the build image. zstd is the modern default.
if command -v zstd >/dev/null 2>&1; then
COMP_EXT="zst"; COMP_CMD=(zstd -c -T0 -19 -)
elif command -v xz >/dev/null 2>&1; then
COMP_EXT="xz"; COMP_CMD=(xz -c -T0 -)
else
COMP_EXT="gz"; COMP_CMD=(gzip -c -)
fi
mkdir -p "$OUT_DIR"
PKGFILE="$OUT_DIR/$PKGNAME-$PKGVER-$PKGREL-x86_64.pkg.tar.$COMP_EXT"
# .PKGINFO MUST be the first entry in the archive — pacman reads it as a stream
# and gives up if the metadata isn't up front. Listing it first is what guarantees
# that ordering; `usr` is appended after.
TAR=tar
command -v bsdtar >/dev/null 2>&1 && TAR=bsdtar
( cd "$STAGE" && "$TAR" -cf - .PKGINFO "${MTREE_ENTRY[@]}" usr ) | "${COMP_CMD[@]}" >"$PKGFILE"
# --- report (the CI log IS the audit trail — see header) --------------------
echo "==> Built $(basename "$PKGFILE") ($(du -h "$PKGFILE" | cut -f1), compression: $COMP_EXT)"
echo "--- .PKGINFO ---"
sed 's/^/ /' "$STAGE/.PKGINFO"
echo "--- contents ---"
"$TAR" -tf "$PKGFILE" | sed 's/^/ /'
echo "==> Install on Arch with: sudo pacman -U $(basename "$PKGFILE")"