imsd: install the published apk instead of building it
Some checks failed
image / image (push) Failing after 48m0s

The imsd repo's package CI is now the only producer of the imsd apk. The
image installs the exact registry package users get via 'apk upgrade'
(pinned version + sha256 of the registry files), so the two can no longer
diverge and the payload-parity rule between two packagings is gone.

pmbootstrap has no knob for a third-party repository, and after the main
'apk add' it re-adds every package in its local packages dir by file path,
which makes apk verify the package's own signature. Registry packages are
signed with per-run keys nobody keeps (phones trust the registry-signed
index), so apk-resign.py replaces the signature stream with one from this
run's abuild key; control and data streams stay byte-identical and the
identity checksum equals the registry's. Verified on the host with apk
3.0.8: originals UNTRUSTED, re-signed OK, checksums equal.

The publish step skips imsd-*: those files came from the registry.
This commit is contained in:
Jorijn van der Graaf 2026-09-02 16:11:50 +02:00
commit e7a1825b50
3 changed files with 130 additions and 36 deletions

View file

@ -44,12 +44,14 @@ jobs:
path: dist/* path: dist/*
if-no-files-found: error if-no-files-found: error
# Ship every locally built apk (kernel, modemmanager, libqmi, imsd, # Ship every locally built apk (kernel, modemmanager, libqmi,
# callaudioshim, audio files, ...) to the Forgejo Alpine registry, so # callaudioshim, audio files, ...) to the Forgejo Alpine registry, so
# installed systems get updates via 'apk upgrade' instead of losing # installed systems get updates via 'apk upgrade' instead of losing
# the FP6 patches to the next upstream version bump. Requires the # the FP6 patches to the next upstream version bump. Requires the
# PACKAGE_TOKEN repo secret (catbot account, package:write scope); # PACKAGE_TOKEN repo secret (catbot account, package:write scope);
# skips quietly until it exists. 409 = same version already published. # skips quietly until it exists. 409 = same version already published.
# imsd is skipped: build.sh 3b took it FROM the registry (re-signed
# for the chroot), so it is not ours to publish.
- name: Publish packages to the apk registry - name: Publish packages to the apk registry
env: env:
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
@ -62,6 +64,9 @@ jobs:
found=0 found=0
for f in /home/build/.local/var/pmbootstrap/packages/*/aarch64/*.apk; do for f in /home/build/.local/var/pmbootstrap/packages/*/aarch64/*.apk; do
[ -e "$f" ] || continue [ -e "$f" ] || continue
case "$(basename "$f")" in
imsd-*) echo "registry-sourced, not republished: $(basename "$f")"; continue ;;
esac
found=1 found=1
code=$(curl -s -o /dev/null -w '%{http_code}' \ code=$(curl -s -o /dev/null -w '%{http_code}' \
--user "catbot:$PACKAGE_TOKEN" --upload-file "$f" \ --user "catbot:$PACKAGE_TOKEN" --upload-file "$f" \

76
apk-resign.py Executable file
View file

@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Re-sign an apk (v2 format) with another RSA key, in place.
apk-resign.py <pkg.apk> <private key .rsa> <public key file name>
An apk is three concatenated gzip streams: the signature tar, the control
tar (.PKGINFO) and the data tar. The signature covers the control stream
only, so replacing the first stream re-signs the package while its identity
checksum (over the control stream) and its contents stay byte-identical.
This is what abuild-sign does to the control segment when abuild packages.
"""
import gzip
import io
import subprocess
import sys
import tarfile
import zlib
DIGEST = {"RSA": "sha1", "RSA256": "sha256", "RSA512": "sha512"}
def gzip_members(data):
off = 0
while off < len(data):
d = zlib.decompressobj(31)
d.decompress(data[off:])
end = len(data) - len(d.unused_data)
if end <= off:
raise ValueError("gzip stream did not advance")
yield data[off:end]
off = end
def first_entry_name(tar):
"""Name of the first regular file in a tar stream, skipping pax headers."""
off = 0
while off + 512 <= len(tar):
hdr = tar[off:off + 512]
size = int(hdr[124:136].split(b"\0")[0].strip() or b"0", 8)
if hdr[156:157] not in (b"x", b"g"): # not a pax extended/global header
return hdr[:100].rstrip(b"\0").decode()
off += 512 + (size + 511) // 512 * 512
raise ValueError("no file entry in signature tar")
def main(path, privkey, pubname):
members = list(gzip_members(open(path, "rb").read()))
if len(members) != 3:
sys.exit(f"{path}: expected 3 gzip streams, found {len(members)}")
old_sig, control, payload = members
# keep the original digest type: the entry is .SIGN.<RSA|RSA256|RSA512>.<key>
name = first_entry_name(gzip.decompress(old_sig))
kind = name.split(".")[2] if name.startswith(".SIGN.") else ""
if kind not in DIGEST:
sys.exit(f"{path}: unexpected signature entry {name!r}")
sig = subprocess.run(
["openssl", "dgst", f"-{DIGEST[kind]}", "-sign", privkey],
input=control, capture_output=True, check=True,
).stdout
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w", format=tarfile.USTAR_FORMAT) as tar:
info = tarfile.TarInfo(f".SIGN.{kind}.{pubname}")
info.size = len(sig)
info.mode = 0o644
tar.addfile(info, io.BytesIO(sig))
# like abuild-tar --cut: header + data blocks, no end-of-archive marker
cut = 512 + (len(sig) + 511) // 512 * 512
new_sig = gzip.compress(buf.getvalue()[:cut], mtime=0)
with open(path, "wb") as f:
f.write(new_sig + control + payload)
if __name__ == "__main__":
if len(sys.argv) != 4:
sys.exit(__doc__)
main(*sys.argv[1:])

View file

@ -1,7 +1,7 @@
#!/bin/sh -eu #!/bin/sh -eu
# fp6-img pipeline: build a flashable postmarketOS image for the Fairphone 6 # fp6-img pipeline: build a flashable postmarketOS image for the Fairphone 6
# with the Catcrafts kernel (milos-linux combined-stable) and, once its tag # with the Catcrafts kernel (milos-linux combined-stable) and imsd (VoLTE)
# is published, imsd (VoLTE). # installed from the Catcrafts apk registry.
# #
# Runs in CI inside an Alpine container on the privileged "pmos" runner # Runs in CI inside an Alpine container on the privileged "pmos" runner
# (pmbootstrap needs loop devices; the aarch64 chroots need the qemu-user # (pmbootstrap needs loop devices; the aarch64 chroots need the qemu-user
@ -17,13 +17,19 @@ set -eu
KERNEL_REPO=https://forgejo.catcrafts.net/Catcrafts/milos-linux.git KERNEL_REPO=https://forgejo.catcrafts.net/Catcrafts/milos-linux.git
KERNEL_BRANCH=combined-stable KERNEL_BRANCH=combined-stable
IMSD_REPO=https://forgejo.catcrafts.net/Catcrafts/imsd.git # imsd is not built here: the image installs the apk the imsd repo's package
# imsd 0.3.1: 0.3.0 + the ims-pdn-up hardening (mmcli errors logged # CI publishes to the registry (section 3b), so image and 'apk upgrade' carry
# verbatim, registration gate, configurable ip-type) + README carrier # the same binary. Pinned by version AND by the sha256 of the registry files;
# updates. The aport now lives IN the imsd repo (packaging/aport/, # a bump is these lines (sha256sum the two apks under $IMSD_REGISTRY/aarch64/).
# transferred 2026-09-01) and is copied out of this checkout below.
# Bump deliberately, not via tip-chasing. # Bump deliberately, not via tip-chasing.
IMSD_COMMIT=17e0f6b # 0.3.1: 0.3.0 + the ims-pdn-up hardening (mmcli errors logged verbatim,
# registration gate, configurable ip-type) + README carrier updates.
IMSD_REGISTRY=https://forgejo.catcrafts.net/api/packages/Catcrafts/alpine/edge/fp6
IMSD_VERSION=0.3.1-r0
IMSD_SHA256="
f1c317d7ff9448c05df068d683d31da08e4bfc074704d96cf52cb8acbdee6304 imsd-0.3.1-r0.apk
a78ef31fc2943ac02e120c46df26353d515ac9840d959cf5193b2afe1c665fa6 imsd-systemd-0.3.1-r0.apk
"
PMAPORTS_REPO=https://gitlab.postmarketos.org/postmarketOS/pmaports.git PMAPORTS_REPO=https://gitlab.postmarketos.org/postmarketOS/pmaports.git
cd "$(dirname "$0")" cd "$(dirname "$0")"
@ -38,7 +44,7 @@ if [ "$(id -u)" = 0 ]; then
# multipath-tools: kpartx; util-linux: losetup with --json support # multipath-tools: kpartx; util-linux: losetup with --json support
# (pmbootstrap's host-tool checks + image mounting need both) # (pmbootstrap's host-tool checks + image mounting need both)
apk add -q git sudo openssl python3 py3-pip multipath-tools util-linux \ apk add -q git sudo openssl python3 py3-pip multipath-tools util-linux \
tar xz tar xz curl
# the pmOS gitlab hiccups under crawler load and truncates clones # the pmOS gitlab hiccups under crawler load and truncates clones
# ("early EOF"); that should cost a retry, not the run — same reasoning # ("early EOF"); that should cost a retry, not the run — same reasoning
# as clone_retry below, which isn't defined yet in this root branch # as clone_retry below, which isn't defined yet in this root branch
@ -138,8 +144,7 @@ cp -r aports/device/fp6-device-tweaks "$WORK/pmaports/device/"
cp -r aports/device/fp6-charging-mode "$WORK/pmaports/device/" cp -r aports/device/fp6-charging-mode "$WORK/pmaports/device/"
cp -r aports/device/catcrafts-fp6-repo "$WORK/pmaports/device/" cp -r aports/device/catcrafts-fp6-repo "$WORK/pmaports/device/"
cp -r aports/main/postmarketos-config-nftables "$WORK/pmaports/main/" cp -r aports/main/postmarketos-config-nftables "$WORK/pmaports/main/"
# imsd's aport is NOT carried here: the imsd repo owns it (packaging/aport/) # imsd has no aport here at all: section 3b installs the published apk.
# and section 2 copies it out of the pinned checkout.
# Alpine forks carrying the GNSS patches (libqmi !470 unreleased; MM !1463 # Alpine forks carrying the GNSS patches (libqmi !470 unreleased; MM !1463
# draft) - deps of modemmanager/imsd, built from aports because r100 > repo. # draft) - deps of modemmanager/imsd, built from aports because r100 > repo.
cp -r aports/temp/libqmi "$WORK/pmaports/temp/" cp -r aports/temp/libqmi "$WORK/pmaports/temp/"
@ -165,22 +170,7 @@ sed -i "s/^_commit=.*/_commit=\"$COMMIT\"/" "$KAPORT/APKBUILD"
KDATE=$(git -C "$WORK/milos-src" log -1 --format=%cd --date=format:%Y%m%d) KDATE=$(git -C "$WORK/milos-src" log -1 --format=%cd --date=format:%Y%m%d)
sed -i "s/^pkgver=\([0-9.]*\)\$/pkgver=\1_git$KDATE/" "$KAPORT/APKBUILD" sed -i "s/^pkgver=\([0-9.]*\)\$/pkgver=\1_git$KDATE/" "$KAPORT/APKBUILD"
# Same dance for imsd, pinned to a reviewed commit rather than branch tip. # Same dance for the gitlab.freedesktop.org packages (libqmi, modemmanager,
# The aport itself comes from the same pinned checkout (packaging/aport/,
# owned by the imsd repo since 2026-09-01), so daemon and packaging can
# never skew.
clone_retry "$WORK/imsd-src" -q "$IMSD_REPO"
git -C "$WORK/imsd-src" checkout -q "$IMSD_COMMIT"
IAPORT="$WORK/pmaports/modem/imsd"
mkdir -p "$WORK/pmaports/modem"
rm -rf "$IAPORT"
cp -r "$WORK/imsd-src/packaging/aport" "$IAPORT"
git -C "$WORK/imsd-src" archive --prefix=imsd/ \
-o "$IAPORT/imsd-$IMSD_COMMIT.tar.gz" HEAD
sed -i "s/^_commit=.*/_commit=\"$IMSD_COMMIT\"/" "$IAPORT/APKBUILD"
# Same again for the gitlab.freedesktop.org packages (libqmi, modemmanager,
# libcamera): their pinned tarballs came from fd.o's on-demand archive # libcamera): their pinned tarballs came from fd.o's on-demand archive
# endpoint, which 503/504s for hours at a stretch — runs #25, #27, #30 and # endpoint, which 503/504s for hours at a stretch — runs #25, #27, #30 and
# #31 all died there, outlasting any in-run retry. git clone is served from # #31 all died there, outlasting any in-run retry. git clone is served from
@ -228,12 +218,11 @@ systemd = always
extra_packages = soc-fairphone-fp6-audio,callaudioshim,imsd,fp6-device-tweaks,fp6-charging-mode,catcrafts-fp6-repo,postmarketos-base-ui-audio-backend-pipewire,pipewire-pulse,pipewire-echo-cancel extra_packages = soc-fairphone-fp6-audio,callaudioshim,imsd,fp6-device-tweaks,fp6-charging-mode,catcrafts-fp6-repo,postmarketos-base-ui-audio-backend-pipewire,pipewire-pulse,pipewire-echo-cancel
EOF EOF
# All five source tarballs are generated locally above, so every checksum # All four source tarballs are generated locally above, so every checksum
# step is offline. libcamera needs a checksum step now too: its committed # step is offline. libcamera needs a checksum step now too: its committed
# sums were for the fd.o-served tarball, and git-archive output is not # sums were for the fd.o-served tarball, and git-archive output is not
# byte-identical to it (verified: sha512 differs). # byte-identical to it (verified: sha512 differs).
pmbootstrap checksum linux-postmarketos-qcom-milos pmbootstrap checksum linux-postmarketos-qcom-milos
pmbootstrap checksum imsd
pmbootstrap checksum libqmi pmbootstrap checksum libqmi
pmbootstrap checksum modemmanager pmbootstrap checksum modemmanager
pmbootstrap checksum libcamera pmbootstrap checksum libcamera
@ -255,12 +244,36 @@ retry "build modemmanager" pmbootstrap $NOCROSS build --arch aarch64 modemmanage
# patched -r2 exists for the publish step even if the install set resolves # patched -r2 exists for the publish step even if the install set resolves
# it before the overlay is considered. # it before the overlay is considered.
retry "build libcamera" pmbootstrap $NOCROSS build --arch aarch64 libcamera retry "build libcamera" pmbootstrap $NOCROSS build --arch aarch64 libcamera
# imsd is the one crossdirect build left to the install phase, which has no # --- 3b. imsd: the published apk, not a local build --------------------------
# per-package flag — pre-build it here in no-crossdirect mode so install # The imsd repo's package CI is the only producer of the imsd apk; the image
# finds it current. # installs the exact registry package users later get via 'apk upgrade'.
if [ -n "$NOCROSS" ]; then # pmbootstrap has no knob for a third-party repository, and after the main
retry "build imsd" pmbootstrap $NOCROSS build --arch aarch64 imsd # 'apk add' it re-adds every package found in its local packages dir BY FILE
# PATH — which makes apk verify the package's own signature, and registry
# packages are signed with per-run keys nobody keeps (phones trust the
# registry-signed index instead). So: fetch, check against the sha256 pin,
# re-sign the envelope with this run's abuild key (control and data streams
# stay byte-identical, so the identity checksum equals the registry's), drop
# into the local packages dir, re-index. The abuild key exists because the
# builds above initialized the buildroot.
IMSD_DL="$WORK/imsd-apk"
rm -rf "$IMSD_DL"
mkdir -p "$IMSD_DL"
for _f in "imsd-$IMSD_VERSION.apk" "imsd-systemd-$IMSD_VERSION.apk"; do
retry "fetch $_f" curl -fsSL -o "$IMSD_DL/$_f" "$IMSD_REGISTRY/aarch64/$_f"
done
(cd "$IMSD_DL" && printf '%s\n' "$IMSD_SHA256" | grep . | sha256sum -c -)
ABUILD_KEY=$(echo "$WORKDIR"/config_abuild/*.rsa)
if [ ! -f "$ABUILD_KEY" ]; then
echo "expected exactly one abuild key in $WORKDIR/config_abuild" >&2
exit 1
fi fi
for _f in "$IMSD_DL"/*.apk; do
python3 ./apk-resign.py "$_f" "$ABUILD_KEY" "$(basename "$ABUILD_KEY").pub"
done
mkdir -p "$WORKDIR/packages/edge/aarch64"
mv "$IMSD_DL"/*.apk "$WORKDIR/packages/edge/aarch64/"
pmbootstrap index
# --- 4. build the image ------------------------------------------------------- # --- 4. build the image -------------------------------------------------------
# Same default credentials as the official postmarketOS images. # Same default credentials as the official postmarketOS images.
@ -291,7 +304,7 @@ cp README.md install.sh "$STAGE/fp6-img/"
echo "kernel: $KERNEL_REPO $KERNEL_BRANCH @ $COMMIT" echo "kernel: $KERNEL_REPO $KERNEL_BRANCH @ $COMMIT"
echo "built: $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "default login: user / 147147 (same as official postmarketOS images)" echo "default login: user / 147147 (same as official postmarketOS images)"
echo "imsd: $IMSD_REPO @ $IMSD_COMMIT ($(apkbuild_var "$IAPORT" pkgver))" echo "imsd: $IMSD_REGISTRY imsd-$IMSD_VERSION (registry package, sha256-pinned)"
} > "$STAGE/fp6-img/build-info.txt" } > "$STAGE/fp6-img/build-info.txt"
# sums of the extracted contents # sums of the extracted contents
(cd "$STAGE/fp6-img" && sha256sum -- * > sha256sums.txt) (cd "$STAGE/fp6-img" && sha256sum -- * > sha256sums.txt)