scripts/release.sh
Ref: Size: 10.0 KiB History
#!/usr/bin/env bash
# Cross-compiled release artifacts for eitri.sh, into dist/<version>/:
# eitri-server_<v>_linux_{amd64,arm64}.tar.gz Linux host bundle: server+agent+unit
# eitri-agent_<v>_darwin_arm64.tar.gz Mac host bundle: agent + LaunchAgent installer
# (a Mac runs guests; it carries no server)
# eitri-cli_<v>_<os>_<arch>.tar.gz client CLI (eitri) for linux+darwin
# eitri-oidc_<v>_linux_{amd64,arm64}.tar.gz bundled OIDC issuer + its unit
# (optional sidecar; not in manifest.json)
# cloud-hypervisor_linux_{amd64,arm64} pinned runtime, mirrored from upstream
# CLOUDHV.fd guest UEFI firmware (if FIRMWARE_SRC set)
# manifest.json agent-upgrade + agent-bootstrap manifest
# (shared type with internal/relmanifest)
# SHA256SUMS over everything above, manifest included
#
# Everything a host installs is a bundle. manifest.json points the agent
# upgrade at the very tarball you would unpack by hand, and the self-updater
# takes it from there: it sniffs gzip and extracts the bundle's eitri-agent
# member (internal/agent/selfupdate). cloud-hypervisor and the firmware stay
# bare — agents bootstrap those directly and they duplicate nothing.
#
# MANIFEST_BASE (env, optional) overrides the URL base written into
# manifest.json — default https://eitri.sh/dl/<version>; set it for staging.
#
# Releases come from clean tagged trees: a -dirty version refuses outright
# (the agent-upgrade path compares versions numerically, so an unparsable
# version silently disables the upgrade button fleet-wide); an untagged HEAD
# warns but proceeds, for staging runs.
#
# The build is byte-reproducible. The same tagged tree with the same
# MANIFEST_BASE yields the same tarballs, the same SHA256SUMS and the same
# manifest.json, however many times it is run: Go builds carry no host paths,
# archive metadata is pinned to the tag's own commit, and gzip records neither
# a name nor a moment. That is what makes a versioned /dl URL immutable in
# fact rather than only in a cache header — scripts/ship.sh compares these
# bytes against what the target already serves and refuses to republish a tag
# as something else.
#
# GNU userland, like the sha256sum below: releases are built on Linux.
set -euo pipefail
cd "$(dirname "$0")/.."
# Pinned cloud-hypervisor mirrored into every release (agents bootstrap it).
# Bump deliberately; update the sha256s from the upstream release page.
CH_VERSION="v53.0"
CH_SHA256_AMD64="448af3d4e59b22c2987f7df94c213ad40fb53a10d437e42b5ee6c4fce7c29ecc"
CH_SHA256_ARM64="f192b510eea1c710cbc439d716bb0573c223fc463dbe3e6523788a2b7ef62850"
VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)"
case "$VERSION" in
*-dirty|dev)
echo "release: refusing to build from a dirty/untracked tree ($VERSION)" >&2
exit 1 ;;
esac
if ! git describe --tags --exact-match >/dev/null 2>&1; then
echo "release: WARNING — HEAD is not a tag ($VERSION); agents never upgrade to unparsable versions" >&2
fi
LDFLAGS="-X github.com/a73x/eitri/internal/version.Version=$VERSION"
OUT="dist/$VERSION"
rm -rf "$OUT"
mkdir -p "$OUT"
STAGE_ROOT="$(mktemp -d)"
trap 'rm -rf "$STAGE_ROOT"' EXIT
# Two inputs the artifacts would otherwise take from whoever is running this:
# the invoking shell's umask, which lands in every archived file's mode, and
# the wall clock. Fix both. The tag's own commit time is the natural reference
# — it is a property of what is being released, so a rebuild years later still
# reproduces it.
umask 022
SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)"
# bundle_tar archives a staged bundle directory reproducibly: entries in name
# order, ownership zeroed, every timestamp the tag's, and a gzip stream that
# records neither the input filename nor the moment it was compressed.
bundle_tar() {
local stage="$1" bundle="$2" out="$3"
tar -C "$stage" --format=gnu --sort=name \
--owner=0 --group=0 --numeric-owner \
--mtime="@$SOURCE_DATE_EPOCH" \
-cf - "$bundle" | gzip -9 -n >"$out"
}
# The Linux host bundle, and the artifact manifest.json names for a Linux
# agent's upgrade: one set of bytes on /dl, whether a person unpacks it or an
# agent swaps itself for the eitri-agent inside it.
for arch in amd64 arm64; do
bundle="eitri-server_${VERSION}_linux_${arch}"
stage="$STAGE_ROOT/$arch"
mkdir -p "$stage/$bundle"
echo "==> building linux/$arch"
CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath -ldflags "$LDFLAGS" \
-o "$stage/$bundle/eitri-server" ./cmd/eitri-server
CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath -ldflags "$LDFLAGS" \
-o "$stage/$bundle/eitri-agent" ./cmd/eitri-agent
install -m 0644 scripts/eitri-agent.service scripts/eitri-server.service "$stage/$bundle/"
bundle_tar "$stage" "$bundle" "$OUT/$bundle.tar.gz"
done
# macOS host bundle: the agent and the script that installs it as a LaunchAgent.
# A generated plist rather than a shipped one, because launchd expands nothing in
# ProgramArguments — every path must be absolute and resolved on the machine that
# will run it. arm64 ONLY, and that is a
# claim about what has booted a guest, not about what compiles — a darwin/amd64
# binary would put an upgrade button in front of an Intel Mac we have never run.
# Without one, such a host gets "no eitri-agent artifact for darwin/amd64",
# which is the honest answer.
#
# No server in this bundle: a Mac joins a fleet as a HOST. No cloud-hypervisor
# or firmware either — a Mac's runtime is vfkit, which carries an Apple
# entitlement in its code signature and so can only come from a signed
# distribution (brew install vfkit), never from a mirror of ours. That is why
# the darwin agent skips the bootstrap the Linux one runs at startup.
mac_bundle="eitri-agent_${VERSION}_darwin_arm64"
mac_stage="$STAGE_ROOT/darwin-arm64"
mkdir -p "$mac_stage/$mac_bundle"
echo "==> building darwin/arm64"
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$LDFLAGS" \
-o "$mac_stage/$mac_bundle/eitri-agent" ./cmd/eitri-agent
install -m 0755 scripts/eitri-agent-launchagent.sh "$mac_stage/$mac_bundle/"
bundle_tar "$mac_stage" "$mac_bundle" "$OUT/$mac_bundle.tar.gz"
# Bundled OIDC issuer — its own tarball (binary + systemd unit) so running with
# or without local OIDC is a pure deployment choice: a fleet fronted by an
# external IdP never downloads it. Like eitri-cli, it carries no agent, so it
# stays out of manifest.json (BuildManifest matches the host bundles, the
# cloud-hypervisor binaries and CLOUDHV.fd).
for arch in amd64 arm64; do
bundle="eitri-oidc_${VERSION}_linux_${arch}"
stage="$STAGE_ROOT/oidc-$arch"
mkdir -p "$stage/$bundle"
echo "==> building eitri-oidc linux/$arch"
CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath -ldflags "$LDFLAGS" \
-o "$stage/$bundle/eitri-oidc" ./cmd/eitri-oidc
install -m 0644 scripts/eitri-oidc.service "$stage/$bundle/"
bundle_tar "$stage" "$bundle" "$OUT/$bundle.tar.gz"
done
# Client CLI, cross-compiled for laptops (pure Go, CGO-free).
for platform in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
os=${platform%%/*} arch=${platform##*/}
bundle="eitri-cli_${VERSION}_${os}_${arch}"
stage="$STAGE_ROOT/cli-$os-$arch"
mkdir -p "$stage/$bundle"
echo "==> building client $platform"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "$LDFLAGS" \
-o "$stage/$bundle/eitri" ./cmd/eitri
bundle_tar "$stage" "$bundle" "$OUT/$bundle.tar.gz"
done
# Mirror the pinned cloud-hypervisor (agents bootstrap it from the manifest).
ch_cache="${CH_CACHE:-$HOME/.cache/eitri/ch}/$CH_VERSION"
mkdir -p "$ch_cache"
for arch in amd64 arm64; do
case "$arch" in
amd64) asset="cloud-hypervisor-static"; want="$CH_SHA256_AMD64" ;;
arm64) asset="cloud-hypervisor-static-aarch64"; want="$CH_SHA256_ARM64" ;;
esac
cached="$ch_cache/$asset"
if [ ! -f "$cached" ]; then
curl -fsSL -o "$cached.part" \
"https://github.com/cloud-hypervisor/cloud-hypervisor/releases/download/$CH_VERSION/$asset"
mv "$cached.part" "$cached"
fi
echo "$want $cached" | sha256sum -c - >/dev/null || {
rm -f "$cached"
echo "release: $asset sha mismatch (want $want) — cache purged, re-run" >&2; exit 1; }
install -m 0755 "$cached" "$OUT/cloud-hypervisor_linux_${arch}"
done
# Firmware rides in the release too (sha-pinned via SHA256SUMS + manifest).
FIRMWARE_SRC="${FIRMWARE_SRC:-}"
if [ -z "$FIRMWARE_SRC" ] && [ -f "$HOME/.cache/eitri/CLOUDHV.fd" ]; then
FIRMWARE_SRC="$HOME/.cache/eitri/CLOUDHV.fd"
fi
if [ -n "$FIRMWARE_SRC" ]; then
install -m 0644 "$FIRMWARE_SRC" "$OUT/CLOUDHV.fd"
else
echo "release: WARNING — no FIRMWARE_SRC; release ships no firmware and agents cannot bootstrap it" >&2
fi
# The API contract rides in the release too (drift-gated in ci, so the
# committed copy is authoritative).
install -m 0644 docs/openapi.json "$OUT/openapi.json"
# The manifest is written before the sums, so the sums cover it. It is the one
# file in here the fleet polls on its own — every agent's upgrade starts by
# reading it — and it is also the one file whose bytes differ per plane, since
# it carries the URLs artifacts are served from. Left out of SHA256SUMS, it is
# invisible to the publish-once check in scripts/ship.sh: two runs of a tag
# against different SITE_HOSTs compare identical while the manifest under that
# immutable URL says something else. Ordering is safe in one direction only,
# and this is it — BuildManifest hashes the artifacts it names by reading them
# and never reads SHA256SUMS, so the manifest depends on the tarballs and the
# sums depend on both.
go run ./cmd/eitri-site manifest -version "$VERSION" -dist "$OUT" \
-base "${MANIFEST_BASE:-https://eitri.sh/dl/$VERSION}"
# The glob is expanded before the redirection creates the file, so SHA256SUMS
# is the only artifact it cannot list: itself.
(cd "$OUT" && LC_ALL=C sha256sum -- * > SHA256SUMS)
echo "==> release staged in $OUT"
ls -lh "$OUT"