#!/usr/bin/env bash # # Turns a published linux-x64 tree into an RPM, and then checks the RPM rather than the exit code. # # Called from two jobs in .github/workflows/ci.yml and written once for the reason the four copies of # "ensure node and git" in that file are not: those cannot be factored out — a composite action would # need the checkout it exists to unblock — and this can. The build job packages the release-shaped # RPM and throws it away, exactly as it does for Windows and macOS; the desktop job packages the # nightly-shaped one and publishes it. Everything that differs between those two is an argument. # # It is also runnable at a desk, which is the point of it being a script: # # dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \ # --configuration Release --runtime linux-x64 --self-contained true \ # -p:RestoreLockedMode=false --output /tmp/linux-x64 # scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm # # Nothing here holds a secret and nothing here uploads anything. Publishing is the calling job's, and # ADR 0013 rule 3 is what decides which channel may do it. set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" payload="" version="" channel="release" out="" while [ $# -gt 0 ]; do case "$1" in --payload) payload="$2"; shift 2 ;; --version) version="$2"; shift 2 ;; --channel) channel="$2"; shift 2 ;; --out) out="$2"; shift 2 ;; *) echo "Unknown argument: $1" >&2; exit 2 ;; esac done for required in payload version out; do if [ -z "${!required}" ]; then echo "--$required is required." >&2 exit 2 fi done case "$channel" in release|nightly) ;; *) echo "--channel must be release or nightly, not '$channel'." >&2; exit 2 ;; esac # The apphost, named rather than globbed. A publish that produced no apphost at all would otherwise # package happily into an RPM whose /usr/bin symlink points at nothing, and the first thing anybody # would learn about it is a shell saying "No such file or directory" for a file that is right there. if [ ! -s "$payload/DodoSSH" ]; then echo "There is no apphost at $payload/DodoSSH, so there is nothing to package." >&2 ls -la "$payload" >&2 || true exit 1 fi # ◆ THE FOUR THINGS THAT DIFFER BETWEEN THE CHANNELS, AND WHY IT IS FOUR AND NOT ONE. # # This is ADR 0013 rule 9's table, in the spelling this platform uses. The package name keeps dnf from # treating one as an upgrade of the other; the directory under /opt keeps the two trees apart on disk; # the /usr/bin name and the desktop entry keep them apart in front of a person, who would otherwise # have two identical launchers and no way to tell which was which. # # The fifth separation — the profile directory holding the vault cache, the outbox and the device key # — is not here and must not be, because it belongs to the build rather than to the package: the # nightly is published with DodoChannel=nightly, and ClientPaths reads that to suffix the directory. # Doing it here as well would be a second answer to a question that already has one. if [ "$channel" = nightly ]; then name=dodossh-nightly dir=DodoSSH.Nightly bin=dodossh-nightly title="DodoSSH Nightly" else name=dodossh dir=DodoSSH bin=dodossh title="DodoSSH" fi # ◆ SEMVER IS NOT AN RPM VERSION, AND THE TRANSLATION IS THE ONE PLACE THIS CAN GO QUIETLY WRONG. # # rpm forbids `-` in a version, which is exactly the character SemVer uses to introduce a prerelease. # MinVer's answer on main is 0.0.1-alpha.0.143, so the naive move — strip the suffix — would give # every nightly the version 0.0.1 and make each one look like a reinstall of the last. # # `~` is rpm's own answer and has been since 4.10: it sorts *before* the empty string, so # 0.0.1~alpha.0.143 is older than 0.0.1 the way alpha.143 is older than the release. Heights keep # rising within a floored version and the first real tag moves past all of them, which is the same # ordering property the Velopack channels have and is worth having on both. # # Build metadata after `+` is dropped rather than translated. It is the one part of a SemVer string # that is defined as not participating in ordering, so keeping it would put a character rpm has no # rule for into the field ordering is done on. rpm_version="${version%%+*}" rpm_version="${rpm_version/-/\~}" case "$rpm_version" in *-*) echo "The version $version translates to $rpm_version, which still contains a hyphen." >&2 echo "rpm cannot order a version with one in it; this needs a rule rather than a retry." >&2 exit 1 ;; esac # Always 1. The release field distinguishes several packagings of one upstream version, and there is # only ever one packaging here — the version already carries the build height, because upstream and # packager are the same repository. rpm_release=1 echo "==> $name $rpm_version-$rpm_release.x86_64, from $payload" # ◆ rpmbuild ON A MUSL HOST, WHICH IS FINE, AND WHY IT IS WORTH SAYING SO. # # The runner is Alpine. Nothing about that shows up in the package: rpmbuild composes an archive and # some metadata, and the one part of it that reads the payload — elfdeps, which the spec leaves on for # requires — parses ELF headers with libelf rather than loading anything, so it reads x86_64 glibc # binaries on a musl host correctly. Building in a Fedora container was the alternative and would # have meant `docker cp`-ing a 130 MB publish tree in and the package back out, because this runner is # itself a container and a bind mount resolves on the daemon's host rather than here — see the note on # the android job for the hour that cost. if ! command -v rpmbuild >/dev/null 2>&1; then SUDO="" [ "$(id -u)" -eq 0 ] || SUDO="sudo" echo "==> installing rpm" if command -v apk >/dev/null 2>&1; then $SUDO apk add --no-cache rpm elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y rpm-build elif command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update -qq $SUDO apt-get install -y --no-install-recommends rpm else echo "No apk, dnf or apt-get here, so rpmbuild cannot be installed from inside the job." >&2 echo "Add rpm to the runner's image; the Linux package cannot be built without it." >&2 exit 1 fi fi rpmbuild --version # ◆ THE LIST OF PRIVATE SONAMES, WORKED OUT FROM THE TREE RATHER THAN WRITTEN DOWN. # # build/linux/dodossh.spec turns rpm's automatic provides off for everything under /opt, because the # libraries there are this application's private copies and have no business satisfying anybody # else's dependency. The requires side has to be told the same thing about the same set, or a bundled # library that links another bundled library asks for a soname nothing provides and the package # becomes uninstallable on every host — which .NET's own libmscordbi.so, linking libmscordaccore.so, # does on every publish. # # Derived here because the set belongs to the payload. A list in the spec would be Microsoft's and # SkiaSharp's internal file names, maintained by hand, going stale on the first runtime bump — and # going stale in the direction that produces a package nobody can install. # # Only the top level is scanned, which is where a .NET publish puts its native libraries. The dots # are escaped because this becomes a regular expression; nothing else in a soname is a metacharacter. # # A shell glob rather than `find -printf`, which is GNU find's and not busybox's — the runner is # Alpine, where that flag is a usage message and an empty list, which would silently disable the whole # exclusion and put the uninstallable package back. bundled="$( for library in "$payload"/lib*.so*; do [ -f "$library" ] && basename "$library" done | sed 's/\./\\./g' | sort -u | paste -sd '|' - )" if [ -z "$bundled" ]; then echo "There are no native libraries at the top of $payload." >&2 echo "A self-contained .NET publish always has some, so this is a publish that went wrong" >&2 echo "rather than a package with nothing to exclude." >&2 exit 1 fi work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT mkdir -p "$out" # --target rather than trusting the host's arch, because the host's arch here is whatever the runner # happens to be and the payload's is decided by the RID the publish asked for. They agree today and # an aarch64 runner would silently make them disagree. # # `dist` is emptied deliberately. It is the %{?dist} tag that puts .fc42 in a filename, and it names # the distribution the package was *built* on — which for a self-contained tree built on Alpine would # be both meaningless and, if rpm guessed something plausible, actively misleading. rpmbuild -bb "$repo_root/build/linux/dodossh.spec" \ --target x86_64 \ --define "_topdir $work" \ --define "_rpmdir $out" \ --define "dist %{nil}" \ --define "dodo_name $name" \ --define "dodo_version $rpm_version" \ --define "dodo_release $rpm_release" \ --define "dodo_dir $dir" \ --define "dodo_bin $bin" \ --define "dodo_title $title" \ --define "dodo_payload $payload" \ --define "dodo_bundled $bundled" \ --define "dodo_icons $repo_root/src/DodoSSH.Client.App/Assets" \ --define "dodo_udev $repo_root/build/linux/71-dodossh-tpm.rules" \ --define "dodo_licence $repo_root/LICENSE" # rpmbuild writes into <_rpmdir>//, and every caller wants the file rather than the tree. rpm_file="$out/x86_64/$name-$rpm_version-$rpm_release.x86_64.rpm" if [ ! -s "$rpm_file" ]; then echo "rpmbuild reported success and there is no package at $rpm_file." >&2 find "$out" >&2 || true exit 1 fi mv "$rpm_file" "$out/" rmdir "$out/x86_64" 2>/dev/null || true rpm_file="$out/$(basename "$rpm_file")" echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes" # ◆ AND NOW THE PART THAT IS NOT PACKAGING: ASKING THE PACKAGE WHAT IT SAYS ABOUT ITSELF. # # Everything above can succeed and still produce something nobody can install, and none of the ways # that happens are visible in an exit code. Each check below is one of them. requires="$(rpm -qp --requires "$rpm_file")" provides="$(rpm -qp --provides "$rpm_file")" files="$(rpm -qp --list "$rpm_file")" # 1. The bundled-soname trap the spec's provides exclusion sets. If some library under /opt ever # gains a DT_NEEDED on another library under /opt, elfdeps will put that soname in the requires # while the exclusion keeps it out of the provides, and the result is a package no host on earth # can satisfy. Nothing about it looks wrong until an install fails, so it is checked here against # the actual contents rather than assumed. carried="$(printf '%s\n' "$files" | sed -n 's|^/opt/.*/\(lib[^/]*\.so[^/]*\)$|\1|p' | sort -u)" unsatisfiable="" for soname in $carried; do if printf '%s\n' "$requires" | grep -qF "$soname("; then unsatisfiable="$unsatisfiable $soname" fi done if [ -n "$unsatisfiable" ]; then echo "This package requires sonames it also carries and does not provide:$unsatisfiable" >&2 echo "That is unsatisfiable on every host, and it means the requires exclusion computed above" >&2 echo "no longer matches what rpm generated — a bundled library outside the top level, or an rpm" >&2 echo "that has stopped honouring __requires_exclude. See build/linux/dodossh.spec." >&2 exit 1 fi # 2. The provides exclusion still doing its job. A package advertising libcoreclr.so to the whole # system is the thing that exclusion exists to prevent, and a future rpm that stopped honouring # the macro would break it silently and in the direction nobody looks. if printf '%s\n' "$provides" | grep -qE '^lib.*\.so'; then echo "This package advertises bundled libraries to the system:" >&2 printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&2 echo "__provides_exclude_from in build/linux/dodossh.spec is meant to stop that." >&2 exit 1 fi # 3. elfdeps having actually run. If it silently did nothing — a runner image whose rpm was built # without libelf would do exactly that — the package would carry no glibc requirement at all and # would install cheerfully onto a host too old to run it. libc.so.6 is the one dependency a # self-contained .NET tree cannot fail to have. if ! printf '%s\n' "$requires" | grep -q '^libc\.so\.6'; then echo "This package does not require libc, which a self-contained .NET tree cannot be true of." >&2 echo "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2 printf '%s\n' "$requires" >&2 exit 1 fi # 4. The four files a person interacts with, named individually. The tree under /opt is thousands of # entries and its absence would be caught by size alone; these are the small ones whose absence # would leave a package that installs, occupies 130 MB and puts nothing on the menu. for expected in \ "/opt/$dir/DodoSSH" \ "/usr/bin/$bin" \ "/usr/share/applications/$bin.desktop" \ "/usr/share/icons/hicolor/256x256/apps/$bin.png" \ "/usr/lib/udev/rules.d/71-dodossh-tpm.rules" do if ! printf '%s\n' "$files" | grep -qxF "$expected"; then echo "The package was built and $expected is not in it." >&2 printf '%s\n' "$files" | head -40 >&2 exit 1 fi done # 5. Nothing world-writable, which on this package is a security property rather than a lint rule. # The spec sets every mode explicitly and says why; this is the check that it worked, because the # failure it guards against arrives by inheritance from a publish directory rather than by anybody # changing this. A world-writable /opt/DodoSSH lets any local account replace the SSH client every # other account on the machine runs. # # Symlinks are excluded because they are always 777 on Linux and the mode means nothing; what the # link points at is what has permissions. writable="$( rpm -qp --queryformat '[%{FILEMODES:perms} %{FILENAMES}\n]' "$rpm_file" | grep -v '^l' | awk '$1 ~ /^.{8}w/ { print }' )" if [ -n "$writable" ]; then echo "This package contains world-writable files:" >&2 printf '%s\n' "$writable" >&2 echo "Any local account could replace them. See the mode block in build/linux/dodossh.spec." >&2 exit 1 fi echo "==> requires:" printf '%s\n' "$requires" | sed 's/^/ /' echo "==> $(basename "$rpm_file") is a $name $rpm_version package and says so." # ◆ THE MANIFEST, WHICH IS WHAT AN INSTALLED CLIENT READS AND NOT WHAT A PERSON READS. # # The same arrangement the android job publishes, for the same reason its comment gives: a release's own # JSON is a large document that changes shape when the forge is upgraded, and the client fetches this on a # six-hourly timer. A small file this repository writes is a contract; a large one the forge writes is not. # # ◆ THE VERSION IN HERE IS THE RPM VERSION, AND THAT IS THE WHOLE POINT OF THE FILE. # # LinuxUpdateChannel compares with rpmvercmp, because rpm is what will accept or refuse the install — the # same argument AndroidChannelManifest makes for versionCode. Publishing the SemVer instead would mean the # client comparing one thing and the package manager another, and they disagree exactly where it matters: # 0.0.1-alpha.0.143 and 0.0.1~alpha.0.143 are the same build, and only the second one sorts below 0.0.1. # # The SemVer goes in too and is used for nothing but the banner. A person asked which nightly they are on # should be able to answer with the string the preferences screen prints, and that screen reads the # assembly. # # ◆ AND THE DIGEST, WHICH THE OTHER TWO CHANNELS HAVE NO NEED OF. # # Velopack verifies what it downloads and Android refuses a package whose signature does not match the # installed one. Nothing about an RPM fetched over HTTPS and handed to PackageKit checks that it is the # file this release published — TLS authenticates the forge and says nothing about the bytes surviving a # proxy, a cache or a partial write. So the manifest names the digest and the client refuses anything else. # # It is not a signature and must not be read as one: it is written by the same job that writes the package, # so it proves integrity and not provenance. Provenance here is that the address is a constant pointing at # the project's own forge — ADR 0013 rule 4 — and a signed repository is what would improve on it. manifest="$out/linux-$channel.json" digest="$(sha256sum "$rpm_file" | cut -d' ' -f1)" cat > "$manifest" < $(basename "$manifest"):" sed 's/^/ /' "$manifest" # The lines the calling job reads. Written to stdout as well as to GITHUB_OUTPUT when there is one, so the # desk use above still tells you where the file went. if [ -n "${GITHUB_OUTPUT:-}" ]; then { echo "rpm=$rpm_file" echo "rpmName=$(basename "$rpm_file")" echo "manifest=$manifest" echo "manifestName=$(basename "$manifest")" } >> "$GITHUB_OUTPUT" fi echo "$rpm_file"