wrolpi/release/release.sh

211 lines
7.9 KiB
Bash
Executable file

#!/bin/bash
# Build, checksum, upload, and publish a WROLPi release.
#
# Runs the full release pipeline as a single command:
# 1. Resolve the version from the target git ref (branch or tag).
# 2. Build the amd64 ISO (debian-live-config) and aarch64 image (pi-gen).
# 3. Generate .sha256 sidecars.
# 4. Upload images + checksums to DigitalOcean Spaces (s3cmd).
# 5. Publish latest.json (the manifest wrolpi.org reads) to the bucket root.
#
# Everything here talks only to public endpoints (github.com, the Spaces CDN).
# Spaces credentials live in the s3cmd config (~/.s3cfg or $S3CFG), never here.
#
# sudo ./release.sh # build the 'release' branch
# sudo ./release.sh -b v0.23.0-beta # build a specific tag
# ./release.sh -b release -n # dry run: build nothing, show upload plan
#
# Building a tag works because the chroot hooks `git clone -b <ref>`, which
# accepts a tag (detached HEAD) just as well as a branch.
set -euo pipefail
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
REPO_DIR=$( cd -- "${SCRIPT_DIR}/.." &> /dev/null && pwd )
# Public constants. None of these are secret; the bucket and CDN host are
# already visible on wrolpi.org.
REPO="lrnselfreliance/wrolpi"
BUCKET="s3://wrolpi"
CDN_BASE="https://wrolpi.nyc3.cdn.digitaloceanspaces.com"
# Cache the manifest briefly so a new release shows up quickly; cache the
# immutable versioned images for a year.
MANIFEST_CACHE="public, max-age=300"
IMAGE_CACHE="public, max-age=31536000, immutable"
# How many times to attempt each upload before giving up. Spaces occasionally
# returns a transient 400 mid-multipart on the multi-GB images, so a single
# failed part should not doom an otherwise-good multi-hour build.
UPLOAD_RETRIES=3
REF="release"
DRY_RUN=0
Help() {
echo "Build and publish a WROLPi release."
echo
echo "Syntax: release.sh [-h] [-b REF] [-n]"
echo "options:"
echo "h Print this help."
echo "b Build from this git branch or tag (default: 'release')."
echo "n Dry run: skip the build and only print the upload/manifest plan."
echo
}
while getopts ":hb:n" option; do
case $option in
h) Help; exit ;;
b) REF="${OPTARG}" ;;
n) DRY_RUN=1 ;;
*) echo "Error: Invalid option"; Help; exit 1 ;;
esac
done
# s3cmd reads $S3CFG if set (handy when this runs as root via systemd but the
# credentials live in a user's home), otherwise its default ~/.s3cfg.
S3CMD=(s3cmd)
[ -n "${S3CFG:-}" ] && S3CMD=(s3cmd -c "${S3CFG}")
log() { echo "[release] $*"; }
die() { echo "[release] ERROR: $*" >&2; exit 1; }
# Optional completion hook. Set WROLPI_RELEASE_NOTIFY to a command that accepts
# a single message argument; it is invoked on success and on failure. Unset by
# default, so this public script names no notifier and no path.
notify() {
[ -n "${WROLPI_RELEASE_NOTIFY:-}" ] || return 0
"${WROLPI_RELEASE_NOTIFY}" "$1" || true
}
# Report the outcome via the notify hook (skipped for dry runs). Installed
# only after the version resolves, so help/early-exit paths stay silent.
on_exit() {
local rc=$?
[ "${DRY_RUN}" -eq 1 ] && return
if [ "${rc}" -eq 0 ]; then
notify "WROLPi v${VERSION} released -- ${CDN_BASE}/latest.json"
else
notify "WROLPi release FAILED (exit ${rc}) for v${VERSION:-?}"
fi
}
# Resolve the version straight from the target ref so the artifact names match
# the code that gets built.
VERSION=$(curl -fsSL "https://raw.githubusercontent.com/${REPO}/${REF}/wrolpi/version.txt" | tr -d '[:space:]')
[ -n "${VERSION}" ] || die "Could not fetch version from ref '${REF}'"
log "Releasing WROLPi v${VERSION} (ref: ${REF})"
trap on_exit EXIT
ISO="${REPO_DIR}/debian-live-config/WROLPi-v${VERSION}-amd64.iso"
IMG="${REPO_DIR}/pi-gen/WROLPi-v${VERSION}-aarch64-desktop.img.xz"
if [ "${DRY_RUN}" -eq 1 ]; then
log "DRY RUN: skipping builds."
else
log "Building amd64 ISO..."
"${REPO_DIR}/debian-live-config/build.sh" -b "${REF}"
log "Building aarch64 image..."
"${REPO_DIR}/pi-gen/build.sh" -b "${REF}"
[ -s "${ISO}" ] || die "Expected ISO not found: ${ISO}"
[ -s "${IMG}" ] || die "Expected image not found: ${IMG}"
fi
# Compute checksum + size for the manifest. In a dry run the artifacts may not
# exist, so fall back to placeholders.
checksum() { [ -s "$1" ] && sha256sum "$1" | cut -d' ' -f1 || echo "DRYRUN"; }
bytesize() { [ -s "$1" ] && stat -c%s "$1" || echo 0; }
ISO_SHA=$(checksum "${ISO}")
IMG_SHA=$(checksum "${IMG}")
ISO_SIZE=$(bytesize "${ISO}")
IMG_SIZE=$(bytesize "${IMG}")
# Write .sha256 sidecars next to the artifacts (standard `sha256sum` format).
if [ "${DRY_RUN}" -eq 0 ]; then
( cd "$(dirname "${ISO}")" && sha256sum "$(basename "${ISO}")" > "${ISO}.sha256" )
( cd "$(dirname "${IMG}")" && sha256sum "$(basename "${IMG}")" > "${IMG}.sha256" )
fi
ISO_NAME=$(basename "${ISO}")
IMG_NAME=$(basename "${IMG}")
RELEASED=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Build latest.json. jq -n keeps the values properly escaped/typed.
# `.tag` is the git ref that was built: a release tag for automated builds
# (release-watch.sh compares it to decide what is already published) or a
# branch name for manual runs. Use `.version` for a display version.
MANIFEST=$(jq -n \
--arg version "${VERSION}" \
--arg tag "${REF}" \
--arg released "${RELEASED}" \
--arg cdn "${CDN_BASE}" \
--arg iso_name "${ISO_NAME}" --arg iso_sha "${ISO_SHA}" --argjson iso_size "${ISO_SIZE}" \
--arg img_name "${IMG_NAME}" --arg img_sha "${IMG_SHA}" --argjson img_size "${IMG_SIZE}" \
'{
version: $version,
tag: $tag,
released: $released,
downloads: [
{ key: "desktop", title: "Raspberry Pi", arch: "aarch64",
filename: $img_name, url: ($cdn + "/" + $img_name),
sha256_url: ($cdn + "/" + $img_name + ".sha256"),
sha256: $img_sha, size: $img_size },
{ key: "debian", title: "Debian", arch: "amd64",
filename: $iso_name, url: ($cdn + "/" + $iso_name),
sha256_url: ($cdn + "/" + $iso_name + ".sha256"),
sha256: $iso_sha, size: $iso_size }
]
}')
MANIFEST_FILE="${SCRIPT_DIR}/latest.json"
echo "${MANIFEST}" > "${MANIFEST_FILE}"
# Abort any in-progress multipart upload for a key. A failed multi-GB upload
# leaves orphaned parts behind that incur storage cost and can interfere with a
# clean retry, so we clear them before re-attempting. Small files never use
# multipart, so this is a harmless no-op for them.
abort_multipart() {
local name="$1"
"${S3CMD[@]}" multipart "${BUCKET}" 2>/dev/null \
| awk -v k="${BUCKET}/${name}" '$2 == k { print $3 }' \
| while read -r id; do
[ -n "${id}" ] && "${S3CMD[@]}" abortmp "${BUCKET}/${name}" "${id}" >/dev/null 2>&1 || true
done
}
put() {
# put <local> <mime> <cache-control>
local src="$1" mime="$2" cache="$3"
local name; name=$(basename "${src}")
if [ "${DRY_RUN}" -eq 1 ]; then
log "DRY RUN would upload: ${name} (${mime})"
return
fi
local attempt
for attempt in $(seq 1 "${UPLOAD_RETRIES}"); do
if "${S3CMD[@]}" put --acl-public --no-progress \
--mime-type="${mime}" \
--add-header="Cache-Control:${cache}" \
"${src}" "${BUCKET}/${name}"; then
return 0
fi
log "Upload of ${name} failed (attempt ${attempt}/${UPLOAD_RETRIES})."
# Clear any partial multipart upload before retrying, then back off briefly.
abort_multipart "${name}"
[ "${attempt}" -lt "${UPLOAD_RETRIES}" ] && sleep 5
done
die "Upload of ${name} failed after ${UPLOAD_RETRIES} attempts."
}
log "Uploading artifacts to ${BUCKET}..."
put "${ISO}" "application/x-iso9660-image" "${IMAGE_CACHE}"
put "${ISO}.sha256" "text/plain" "${IMAGE_CACHE}"
put "${IMG}" "application/x-xz" "${IMAGE_CACHE}"
put "${IMG}.sha256" "text/plain" "${IMAGE_CACHE}"
log "Publishing manifest..."
put "${MANIFEST_FILE}" "application/json" "${MANIFEST_CACHE}"
log "Done. Published v${VERSION}:"
log " ${CDN_BASE}/${ISO_NAME}"
log " ${CDN_BASE}/${IMG_NAME}"
log " ${CDN_BASE}/latest.json"