#!/usr/bin/env bash # publish-apk [--host H] [--dir D] [--keep N] # # From another machine: PUBLISH_SSH=user@tungsten publish-apk # # Publishes a built APK to the static site, verifying every step that has gone wrong before: # the host, that the file is really an APK, that what landed is byte-identical to what was # tested, and that the web server actually serves it. # # Exists because publishing is where a silent mistake is most expensive. A stale published APK # once sat for eight weeks while the repo moved on; installing it over current data threw # "A migration from 11 to 6 was required but not found" and the app refused to start. The cost # wasn't the bug, it was that nothing in the publish step had ever checked. set -uo pipefail die() { echo "publish-apk: $1" >&2; exit 2; } EXPECT_HOST="tungsten" DEST_DIR="/site/static.terst.org/public/files" # Each backup is a full copy of a large APK, in a public directory. Four publishes in one # evening left 448MB of them. Keep enough to roll back, not enough to become the problem. KEEP_BACKUPS=2 [ $# -ge 2 ] || die "usage: publish-apk [--host H] [--dir D]" APK="$1"; NAME="$2"; shift 2 while [ $# -gt 0 ]; do case "$1" in --host) EXPECT_HOST="$2"; shift 2 ;; --dir) DEST_DIR="$2"; shift 2 ;; --keep) KEEP_BACKUPS="$2"; shift 2 ;; *) die "unknown option '$1'" ;; esac done case "$NAME" in */*) die "published name must be a bare filename, got '$NAME'" ;; *.apk) ;; *) die "published name should end in .apk, got '$NAME'" ;; esac # Publishing from a box that merely resembles the deploy host is how a previous session # "deployed" to a sandbox and had to unwind it. ACTUAL_HOST=$(hostname) REMOTE=0 if [ "$ACTUAL_HOST" != "$EXPECT_HOST" ]; then # Publishing from elsewhere is allowed, but only by sending the file to the real host -- # never by writing to a local path that merely looks like it. A previous session # "deployed" to a sandbox exactly that way and had to unwind it. if [ -n "${PUBLISH_SSH:-}" ]; then REMOTE=1 else die "host is '$ACTUAL_HOST', expected '$EXPECT_HOST'. To publish from here, send it over ssh instead: PUBLISH_SSH= publish-apk That copies to the real host rather than writing to a local path of the same name." fi fi [ -f "$APK" ] || die "no such file: $APK" # An .apk is a zip; an HTML error page or a truncated copy is not. # # Deliberately NOT `... | grep -q`: under `set -o pipefail` grep exits on its first match and # SIGPIPEs the producer, so the pipeline reports failure on a file that is perfectly valid. # This script hit exactly that on a real 112MB APK -- the trap is in this repo's own README, # and it still caught me. Capture first, test second. MAGIC=$(head -c 2 "$APK") [ "$MAGIC" = "PK" ] || die "$APK is not a zip archive — not a real APK" LISTING=$(unzip -l "$APK" 2>/dev/null) case "$LISTING" in *AndroidManifest.xml*) ;; *) die "$APK has no AndroidManifest.xml — not an APK" ;; esac if [ $REMOTE -eq 0 ]; then [ -d "$DEST_DIR" ] || die "no such directory: $DEST_DIR" fi DEST="$DEST_DIR/$NAME" echo "=== publish-apk ===" echo "host : $ACTUAL_HOST$([ $REMOTE -eq 1 ] && echo " (publishing to $PUBLISH_SSH over ssh)")" echo "source : $APK ($(stat -c%s "$APK") bytes)" echo "dest : $DEST" if [ $REMOTE -eq 1 ]; then echo "source : $APK ($(stat -c%s "$APK") bytes)" LOCAL_SHA=$(sha256sum "$APK" | awk '{print $1}') # Copy to a temp name first, so a half-transferred file is never the published one. scp -q "$APK" "$PUBLISH_SSH:/tmp/$NAME.incoming" || die "scp failed" ssh "$PUBLISH_SSH" "bash -s" <&2; exit 1; } [ -f "\$dest" ] && cp "\$dest" "\$dest.\$(date +%Y%m%d-%H%M%S).bak" ls -t "\$dest".*.bak 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | while read -r old; do rm -f "\$old"; done mv "/tmp/$NAME.incoming" "\$dest" chmod 644 "\$dest" chown root:root "\$dest" 2>/dev/null || true sha256sum "\$dest" | awk '{print \$1}' REMOTE_SCRIPT REMOTE_SHA=$(ssh "$PUBLISH_SSH" "sha256sum '$DEST_DIR/$NAME'" 2>/dev/null | awk '{print $1}') if [ "$LOCAL_SHA" = "$REMOTE_SHA" ]; then echo "identity: published copy is byte-identical to the source" else die "published copy differs from the source (local $LOCAL_SHA, remote $REMOTE_SHA)" fi URL="https://static.terst.org/files/$NAME" REMOTE_LEN=$(curl -sI "$URL" 2>/dev/null | tr -d '\r' | awk 'tolower($1)=="content-length:"{print $2}') LOCAL_LEN=$(stat -c%s "$APK") echo "url : $URL" if [ "$REMOTE_LEN" = "$LOCAL_LEN" ]; then echo "result : PASS -- served bytes match the tested build" exit 0 fi echo "result : FAIL -- server reports ${REMOTE_LEN:-nothing}, built $LOCAL_LEN" exit 1 fi if [ -f "$DEST" ]; then BACKUP="$DEST.$(date +%Y%m%d-%H%M%S).bak" cp "$DEST" "$BACKUP" || die "could not back up the existing file" echo "backup : $BACKUP" else echo "backup : none needed (new file)" fi # Prune before copying, so a failed publish doesn't leave the pruning undone too. PRUNED=$(ls -t "$DEST".*.bak 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1))) if [ -n "$PRUNED" ]; then echo "$PRUNED" | while read -r old; do rm -f "$old"; done echo "pruned : $(echo "$PRUNED" | wc -l) old backup(s), keeping the newest $KEEP_BACKUPS" fi cp "$APK" "$DEST" || die "copy failed" chmod 644 "$DEST" || die "chmod failed" chown root:root "$DEST" 2>/dev/null || true # The whole point: what is served must be what was tested. if cmp -s "$APK" "$DEST"; then echo "identity: published copy is byte-identical to the source" else die "published copy differs from the source — do not trust it" fi URL="https://static.terst.org/files/$NAME" REMOTE_LEN=$(curl -sI "$URL" 2>/dev/null | tr -d '\r' | awk 'tolower($1)=="content-length:"{print $2}') LOCAL_LEN=$(stat -c%s "$DEST") if [ -z "$REMOTE_LEN" ]; then echo "serving : COULD NOT VERIFY (no content-length from $URL)" echo "result : PARTIAL — the file is in place but the web server did not confirm it" exit 1 fi echo "url : $URL" echo "serving : $REMOTE_LEN bytes" if [ "$REMOTE_LEN" = "$LOCAL_LEN" ]; then echo "result : PASS — served bytes match the tested build" else echo "result : FAIL — server reports $REMOTE_LEN, on disk $LOCAL_LEN" exit 1 fi