Skip to content

chore(release): 1.0.0 — general availability #149

chore(release): 1.0.0 — general availability

chore(release): 1.0.0 — general availability #149

Workflow file for this run

name: Release
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., v1.0.0)'
required: true
type: string
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# Optimize build performance
RUSTFLAGS: "-C link-arg=-fuse-ld=lld"
jobs:
# Gate: Ensure CI passes before building release
ci-gate:
name: CI Gate
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Check formatting
run: cargo fmt -- --check
- name: Run clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Run security audit
run: cargo audit
- name: Check shell scripts
run: |
sudo apt-get update && sudo apt-get install -y shellcheck
find scripts/install -name "*.sh" -type f -exec bash -n {} \;
find scripts/install -name "*.sh" -type f -exec shellcheck -x {} \;
build-binaries:
name: Build Release Binaries
needs: ci-gate
runs-on: self-hosted
# Alternatives:
# runs-on: ubuntu-24.04
# runs-on: buildjet-4vcpu-ubuntu-2204
strategy:
matrix:
target:
- x86_64-unknown-linux-gnu
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu,x86_64-unknown-linux-musl
- name: Install system dependencies
run: |
# Skip the package-manager dance on a runner that's already
# provisioned (typical for self-hosted Arch / RHEL boxes where
# apt-get isn't available). Falls through to apt on a fresh
# Debian/Ubuntu runner.
MISSING=""
for t in cargo pkg-config clang ld.lld musl-gcc psql; do
command -v "$t" >/dev/null || MISSING="$MISSING $t"
done
if [ -z "$MISSING" ]; then
echo "All build tools present; skipping system package install."
exit 0
fi
echo "Missing tools:$MISSING"
if command -v apt-get >/dev/null; then
sudo apt-get update
sudo apt-get install -y \
build-essential pkg-config libssl-dev \
musl-tools lld clang \
postgresql postgresql-contrib
else
echo "::error::Missing tools and no apt-get available — provision the runner manually."
exit 1
fi
- name: Setup PostgreSQL
run: |
# If a local Postgres is already reachable as nexus/nexus on the
# default or dockerized port, use it. Avoids needing sudo to
# bootstrap a fresh Postgres on a self-hosted runner.
for port in 5432 5435; do
if PGPASSWORD=nexus psql -h 127.0.0.1 -p "$port" -U nexus -d nexus -c '\q' 2>/dev/null; then
echo "Local postgres reachable on :$port — skipping bootstrap."
echo "DATABASE_URL=postgres://nexus:nexus@127.0.0.1:$port/nexus" >> "$GITHUB_ENV"
exit 0
fi
done
# No reachable Postgres — fall back to the apt-style bootstrap.
sudo systemctl start postgresql || true
sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname = 'nexus'" | grep -q 1 || \
sudo -u postgres psql -c "CREATE DATABASE nexus;"
sudo -u postgres psql -tc "SELECT 1 FROM pg_user WHERE usename = 'nexus'" | grep -q 1 || \
sudo -u postgres psql -c "CREATE USER nexus WITH PASSWORD 'nexus';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE nexus TO nexus;" || true
sudo -u postgres psql -d nexus -c "GRANT ALL ON SCHEMA public TO nexus;" || true
# CREATEROLE needed for migration 23 (audit schema creates audit_reader role)
sudo -u postgres psql -c "ALTER USER nexus CREATEROLE;" || true
- name: Setup sccache (local)
run: |
# Install sccache to ~/.cargo/bin (already on PATH) — no sudo needed,
# works on self-hosted runners without passwordless-sudo configured.
if ! command -v sccache &> /dev/null; then
mkdir -p "$HOME/.cargo/bin"
curl -L https://github.com/mozilla/sccache/releases/download/v0.7.7/sccache-v0.7.7-x86_64-unknown-linux-musl.tar.gz \
| tar xz -C /tmp
mv /tmp/sccache-*/sccache "$HOME/.cargo/bin/"
chmod +x "$HOME/.cargo/bin/sccache"
fi
# Use local disk cache
echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV
echo "SCCACHE_DIR=$HOME/.cache/sccache" >> $GITHUB_ENV
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-
- name: Build manager (static musl for portability)
run: cargo build --release --target x86_64-unknown-linux-musl -p manager
env:
DATABASE_URL: postgresql://nexus:nexus@localhost/nexus
# Use vendored OpenSSL for musl builds
OPENSSL_STATIC: 1
OPENSSL_LIB_DIR: /usr/lib/x86_64-linux-gnu
OPENSSL_INCLUDE_DIR: /usr/include
- name: Build agent (static musl for portability)
run: cargo build --release --target x86_64-unknown-linux-musl -p agent
env:
OPENSSL_STATIC: 1
OPENSSL_LIB_DIR: /usr/lib/x86_64-linux-gnu
OPENSSL_INCLUDE_DIR: /usr/include
- name: Build guest-agent (static musl)
run: cargo build --release --target x86_64-unknown-linux-musl -p guest-agent
- name: Build installer (static musl)
run: cargo build --release --target x86_64-unknown-linux-musl -p nqr-installer
env:
LICENSE_API_KEY: ${{ secrets.LICENSE_API_KEY }}
LICENSE_PUBLIC_KEY: ${{ secrets.LICENSE_PUBLIC_KEY }}
- name: Verify binaries
run: |
# Verify all binaries are statically linked
for bin in manager agent guest-agent nqr-installer; do
test -x ./target/x86_64-unknown-linux-musl/release/$bin
file ./target/x86_64-unknown-linux-musl/release/$bin
# Check it's statically linked (handles both "statically linked" and "static-pie linked")
file ./target/x86_64-unknown-linux-musl/release/$bin | grep -E "static.*linked" || {
echo "WARNING: $bin may not be statically linked"
ldd ./target/x86_64-unknown-linux-musl/release/$bin || true
}
done
- name: Rename binaries with target suffix
run: |
cp target/x86_64-unknown-linux-musl/release/manager nqrust-manager-x86_64-linux-musl
cp target/x86_64-unknown-linux-musl/release/agent nqrust-agent-x86_64-linux-musl
cp target/x86_64-unknown-linux-musl/release/guest-agent nqrust-guest-agent-x86_64-linux-musl
cp target/x86_64-unknown-linux-musl/release/nqr-installer nqr-installer-x86_64-linux-musl
- name: Upload binaries as artifacts
uses: actions/upload-artifact@v4
with:
name: binaries-${{ matrix.target }}
path: |
nqrust-manager-x86_64-linux-musl
nqrust-agent-x86_64-linux-musl
nqrust-guest-agent-x86_64-linux-musl
nqr-installer-x86_64-linux-musl
retention-days: 1
- name: Show sccache statistics
run: sccache --show-stats
build-images:
name: Build Base Images
needs: [ci-gate, build-binaries]
# Build images on every stable tag (vX.Y.Z) so the GitHub release
# carries the kernel + rootfs assets the general installer downloads
# via scripts/install/install.sh → nqr-installer → download_base_images.
# Skip on prereleases (alpha/beta/rc) since those are short-lived and
# operators are expected to install from a prior stable release. The
# `BUILD_IMAGES=true` repo variable forces a build for those tags
# when the kernel / rootfs / runtime sources actually changed.
#
# The earlier "skip by default" rule (commit history) assumed the
# installer would fall back to a previous release's assets — it
# never did, so v0.4.0 / v0.4.1 shipped without base images and the
# general installer silently produced an empty /srv/images.
if: ${{ vars.BUILD_IMAGES == 'true' || !contains(github.ref_name, '-') }}
runs-on: self-hosted
# This job builds VM images (kernels, rootfs, runtimes)
# Requires: KVM access, ~10GB disk space, internet for downloads
# Now depends on build-binaries so guest-agent binary is available
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Clean up and check disk space
run: |
# Clean up from any previous runs - INCLUDING alpine cache to ensure fresh builds
sudo rm -rf /tmp/ubuntu-rootfs /tmp/node-rootfs /tmp/python-rootfs /tmp/alpine-*.tar.gz 2>/dev/null || true
sudo rm -rf /mnt/alpine /mnt/busybox /mnt/ubuntu /mnt/node /mnt/python /mnt/container 2>/dev/null || true
sudo rm -rf /tmp/alpine-cache 2>/dev/null || true
# Clean up any old build directories from runtime scripts
sudo rm -rf /tmp/build-node-runtime /tmp/build-python-runtime /tmp/build-bun-runtime 2>/dev/null || true
sudo rm -rf "$(pwd)/build-node-runtime" "$(pwd)/build-python-runtime" "$(pwd)/build-bun-runtime" 2>/dev/null || true
# IMPORTANT: Remove any cached runtime images from previous builds on self-hosted runner
sudo rm -f /srv/images/node-runtime.ext4 /srv/images/python-runtime.ext4 /srv/images/bun-runtime.ext4 2>/dev/null || true
# Show available disk space
echo "=== Disk space ==="
df -h /tmp
df -h .
- name: Install dependencies
run: |
# Same self-hosted-tolerant logic as the binaries job: skip
# apt-get if the tools we actually need are already on PATH.
MISSING=""
for t in qemu-img debootstrap mkfs.ext4 curl xz cpio bc flex bison; do
command -v "$t" >/dev/null || MISSING="$MISSING $t"
done
if [ -z "$MISSING" ]; then
echo "All image-build tools present; skipping system package install."
exit 0
fi
echo "Missing tools:$MISSING"
if command -v apt-get >/dev/null; then
sudo apt-get update
sudo apt-get install -y \
qemu-utils debootstrap e2fsprogs curl xz-utils cpio bc \
flex bison libelf-dev libssl-dev
else
echo "::error::Missing tools and no apt-get available — provision the runner manually."
exit 1
fi
- name: Create images directory
run: |
# Remove any existing images to force fresh builds
rm -rf images/
mkdir -p images/
- name: Download guest-agent from build-binaries
uses: actions/download-artifact@v4
with:
name: binaries-x86_64-unknown-linux-gnu
path: /tmp/binaries/
- name: Setup guest-agent for image builds
run: |
# Copy guest-agent to expected location for build scripts
mkdir -p target/x86_64-unknown-linux-musl/release/
cp /tmp/binaries/nqrust-guest-agent-x86_64-linux-musl target/x86_64-unknown-linux-musl/release/guest-agent
chmod +x target/x86_64-unknown-linux-musl/release/guest-agent
echo "Guest-agent ready for image builds:"
ls -la target/x86_64-unknown-linux-musl/release/guest-agent
- name: Download Firecracker kernel
run: |
# Download pre-built Firecracker kernel 5.10
curl -fsSL -o images/vmlinux-5.10.fc.bin \
https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/x86_64/kernels/vmlinux.bin
ls -lh images/vmlinux-5.10.fc.bin
- name: Build Alpine rootfs
run: |
# Build Alpine image with REAL OpenRC init system using apk.static
# This produces images identical to the working dev server images
ALPINE_VERSION=3.18
# Download apk-tools-static to bootstrap Alpine
curl -fsSL -o /tmp/apk-static.apk \
https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION}/main/x86_64/apk-tools-static-2.14.4-r0.apk
# Extract apk.static binary
mkdir -p /tmp/apk-extract
tar -xzf /tmp/apk-static.apk -C /tmp/apk-extract 2>/dev/null || true
chmod +x /tmp/apk-extract/sbin/apk.static
# Create ext4 image (100MB for Alpine with OpenRC)
dd if=/dev/zero of=images/alpine-3.18-minimal.ext4 bs=1M count=100
mkfs.ext4 -F images/alpine-3.18-minimal.ext4
# Mount and bootstrap with apk.static
sudo mkdir -p /mnt/alpine
sudo mount -o loop images/alpine-3.18-minimal.ext4 /mnt/alpine
# Bootstrap full Alpine with OpenRC
sudo /tmp/apk-extract/sbin/apk.static \
-X https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION}/main \
-U --allow-untrusted --root /mnt/alpine --initdb \
add alpine-base openrc
# Set hostname
echo 'alpine-vm' | sudo tee /mnt/alpine/etc/hostname > /dev/null
# Create resolv.conf
echo 'nameserver 8.8.8.8' | sudo tee /mnt/alpine/etc/resolv.conf > /dev/null
# Set root password (password: root)
sudo chroot /mnt/alpine /bin/sh -c 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; echo "root:root" | chpasswd'
# Create inittab for OpenRC (matches working dev image exactly)
printf '%s\n' \
'# /etc/inittab' \
'' \
'::sysinit:/sbin/openrc sysinit' \
'::sysinit:/sbin/openrc boot' \
'::wait:/sbin/openrc default' \
'' \
'# Serial console' \
'ttyS0::respawn:/sbin/getty -L ttyS0 115200 vt100' \
'' \
'::ctrlaltdel:/sbin/reboot' \
'::shutdown:/sbin/openrc shutdown' \
| sudo tee /mnt/alpine/etc/inittab > /dev/null
# Configure networking
printf '%s\n' \
'auto lo' \
'iface lo inet loopback' \
'' \
'auto eth0' \
'iface eth0 inet dhcp' \
| sudo tee /mnt/alpine/etc/network/interfaces > /dev/null
# Enable networking service in default runlevel
sudo chroot /mnt/alpine /sbin/rc-update add networking default 2>/dev/null || \
sudo ln -sf /etc/init.d/networking /mnt/alpine/etc/runlevels/default/networking
# Create udhcpc default script
sudo mkdir -p /mnt/alpine/etc/udhcpc
printf '%s\n' \
'#!/bin/sh' \
'[ -z "$1" ] && echo "Error: should be called from udhcpc" && exit 1' \
'' \
'case "$1" in' \
' deconfig)' \
' ip addr flush dev $interface 2>/dev/null || ifconfig $interface 0.0.0.0' \
' ;;' \
' renew|bound)' \
' CIDR=24' \
' case "$subnet" in' \
' 255.255.255.0) CIDR=24 ;;' \
' 255.255.255.128) CIDR=25 ;;' \
' 255.255.255.192) CIDR=26 ;;' \
' 255.255.0.0) CIDR=16 ;;' \
' 255.0.0.0) CIDR=8 ;;' \
' esac' \
' ip addr add $ip/$CIDR dev $interface 2>/dev/null || ifconfig $interface $ip netmask ${subnet:-255.255.255.0}' \
' [ -n "$router" ] && (ip route add default via $router 2>/dev/null || route add default gw $router)' \
' if [ -n "$dns" ]; then' \
' echo -n > /etc/resolv.conf' \
' for i in $dns; do echo "nameserver $i" >> /etc/resolv.conf; done' \
' fi' \
' ;;' \
'esac' \
'exit 0' \
| sudo tee /mnt/alpine/etc/udhcpc/default.script > /dev/null
sudo chmod +x /mnt/alpine/etc/udhcpc/default.script
# Set TERM for serial console
sudo mkdir -p /mnt/alpine/etc/profile.d
echo 'export TERM=vt100' | sudo tee /mnt/alpine/etc/profile.d/term.sh
sudo chmod +x /mnt/alpine/etc/profile.d/term.sh
sudo umount /mnt/alpine
rm -rf /tmp/apk-extract /tmp/apk-static.apk
ls -lh images/alpine-3.18-minimal.ext4
- name: Build BusyBox rootfs
run: |
# Build "BusyBox" image using Alpine Linux base with REAL OpenRC
# The dev server's busybox-1.36.ext4 is actually Alpine with OpenRC
ALPINE_VERSION=3.18
# Download apk-tools-static to bootstrap Alpine
curl -fsSL -o /tmp/apk-static.apk \
https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION}/main/x86_64/apk-tools-static-2.14.4-r0.apk
# Extract apk.static binary
mkdir -p /tmp/apk-extract
tar -xzf /tmp/apk-static.apk -C /tmp/apk-extract 2>/dev/null || true
chmod +x /tmp/apk-extract/sbin/apk.static
# Create ext4 image (100MB for Alpine-based BusyBox with OpenRC)
dd if=/dev/zero of=images/busybox-1.35.ext4 bs=1M count=100
mkfs.ext4 -F images/busybox-1.35.ext4
# Mount and bootstrap with apk.static
sudo mkdir -p /mnt/busybox
sudo mount -o loop images/busybox-1.35.ext4 /mnt/busybox
# Bootstrap full Alpine with OpenRC
sudo /tmp/apk-extract/sbin/apk.static \
-X https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION}/main \
-U --allow-untrusted --root /mnt/busybox --initdb \
add alpine-base openrc
# Set hostname
echo 'busybox-vm' | sudo tee /mnt/busybox/etc/hostname > /dev/null
# Create resolv.conf
echo 'nameserver 8.8.8.8' | sudo tee /mnt/busybox/etc/resolv.conf > /dev/null
# Set root password (password: root)
sudo chroot /mnt/busybox /bin/sh -c 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; echo "root:root" | chpasswd'
# Create inittab for OpenRC (matches working dev image exactly)
printf '%s\n' \
'# /etc/inittab' \
'' \
'::sysinit:/sbin/openrc sysinit' \
'::sysinit:/sbin/openrc boot' \
'::wait:/sbin/openrc default' \
'' \
'# Serial console' \
'ttyS0::respawn:/sbin/getty -L ttyS0 115200 vt100' \
'' \
'::ctrlaltdel:/sbin/reboot' \
'::shutdown:/sbin/openrc shutdown' \
| sudo tee /mnt/busybox/etc/inittab > /dev/null
# Configure networking
printf '%s\n' \
'auto lo' \
'iface lo inet loopback' \
'' \
'auto eth0' \
'iface eth0 inet dhcp' \
| sudo tee /mnt/busybox/etc/network/interfaces > /dev/null
# Enable networking service in default runlevel
sudo chroot /mnt/busybox /sbin/rc-update add networking default 2>/dev/null || \
sudo ln -sf /etc/init.d/networking /mnt/busybox/etc/runlevels/default/networking
# Create udhcpc default script (matches working image)
sudo mkdir -p /mnt/busybox/etc/udhcpc
printf '%s\n' \
'#!/bin/sh' \
'[ -z "$1" ] && echo "Error: should be called from udhcpc" && exit 1' \
'' \
'case "$1" in' \
' deconfig)' \
' ip addr flush dev $interface' \
' ;;' \
' renew|bound)' \
' CIDR=24' \
' case "$subnet" in' \
' 255.255.255.0) CIDR=24 ;;' \
' 255.255.255.128) CIDR=25 ;;' \
' 255.255.255.192) CIDR=26 ;;' \
' 255.255.0.0) CIDR=16 ;;' \
' 255.0.0.0) CIDR=8 ;;' \
' esac' \
' ip addr add $ip/$CIDR dev $interface' \
' [ -n "$router" ] && ip route add default via $router dev $interface 2>/dev/null || true' \
' if [ -n "$dns" ]; then' \
' echo -n > /etc/resolv.conf' \
' for i in $dns; do echo "nameserver $i" >> /etc/resolv.conf; done' \
' fi' \
' ;;' \
'esac' \
'exit 0' \
| sudo tee /mnt/busybox/etc/udhcpc/default.script > /dev/null
sudo chmod +x /mnt/busybox/etc/udhcpc/default.script
# Set TERM for serial console
sudo mkdir -p /mnt/busybox/etc/profile.d
echo 'export TERM=vt100' | sudo tee /mnt/busybox/etc/profile.d/term.sh
sudo chmod +x /mnt/busybox/etc/profile.d/term.sh
sudo umount /mnt/busybox
rm -rf /tmp/apk-extract /tmp/apk-static.apk
ls -lh images/busybox-1.35.ext4
- name: Build Ubuntu minimal rootfs
run: |
# Download official Ubuntu 24.04 cloud image (minimal rootfs)
# Using cloud image instead of debootstrap - faster and more reliable
UBUNTU_URL="https://cloud-images.ubuntu.com/minimal/releases/noble/release/ubuntu-24.04-minimal-cloudimg-amd64-root.tar.xz"
echo "Downloading Ubuntu 24.04 minimal cloud image..."
curl -fsSL -o /tmp/ubuntu-rootfs.tar.xz "$UBUNTU_URL"
# Create ext4 image (800MB for Ubuntu minimal)
dd if=/dev/zero of=images/ubuntu-24.04-minimal.ext4 bs=1M count=800
mkfs.ext4 -F images/ubuntu-24.04-minimal.ext4
# Mount and extract
sudo mkdir -p /mnt/ubuntu
sudo mount -o loop images/ubuntu-24.04-minimal.ext4 /mnt/ubuntu
sudo tar -xJf /tmp/ubuntu-rootfs.tar.xz -C /mnt/ubuntu
# Set root password
sudo chroot /mnt/ubuntu /bin/sh -c 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; echo "root:root" | chpasswd'
# Enable serial console
sudo chroot /mnt/ubuntu /usr/bin/systemctl enable serial-getty@ttyS0.service || true
# Set default TERM for serial console
sudo mkdir -p /mnt/ubuntu/etc/profile.d
echo 'export TERM=vt100' | sudo tee /mnt/ubuntu/etc/profile.d/term.sh
sudo chmod +x /mnt/ubuntu/etc/profile.d/term.sh
# Configure networking
sudo tee /mnt/ubuntu/etc/netplan/01-dhcp.yaml > /dev/null <<'NETPLAN'
network:
version: 2
ethernets:
eth0:
dhcp4: true
NETPLAN
sudo umount /mnt/ubuntu
# Clean up
rm -f /tmp/ubuntu-rootfs.tar.xz
ls -lh images/ubuntu-24.04-minimal.ext4
- name: Build Node.js function runtime
run: |
cd $GITHUB_WORKSPACE
# Script auto-converts relative OUTPUT_IMAGE to absolute path
sudo OUTPUT_IMAGE="images/node-runtime.ext4" bash scripts/runtime-images/build-node-runtime.sh
ls -lh images/node-runtime.ext4
- name: Build Python function runtime
run: |
cd $GITHUB_WORKSPACE
# Script auto-converts relative OUTPUT_IMAGE to absolute path
sudo OUTPUT_IMAGE="images/python-runtime.ext4" bash scripts/runtime-images/build-python-runtime.sh
ls -lh images/python-runtime.ext4
- name: Build Bun function runtime
run: |
cd $GITHUB_WORKSPACE
# Script auto-converts relative OUTPUT_IMAGE to absolute path
sudo OUTPUT_IMAGE="images/bun-runtime.ext4" bash scripts/runtime-images/build-bun-runtime.sh
ls -lh images/bun-runtime.ext4
- name: Build container runtime (Alpine + Docker)
run: |
# Build container runtime for Docker-in-VM feature
# Uses the same approach as scripts/build-container-runtime-v2.sh
ALPINE_VERSION=3.18
WORK_DIR="$(pwd)/build-container-runtime"
# Clean up any leftover build directory from previous failed runs
sudo rm -rf "$WORK_DIR" || true
mkdir -p "$WORK_DIR"
cd "$WORK_DIR"
# Download Alpine minirootfs
curl -fsSL -o alpine-minirootfs.tar.gz \
"https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION}/releases/x86_64/alpine-minirootfs-${ALPINE_VERSION}.0-x86_64.tar.gz"
# Extract Alpine
mkdir -p rootfs
tar xzf alpine-minirootfs.tar.gz -C rootfs
# Copy DNS config for package installation
sudo cp /etc/resolv.conf rootfs/etc/resolv.conf
# Create setup script inside rootfs
cat > rootfs/tmp/setup.sh << 'SETUP_SCRIPT'
#!/bin/sh
set -e
# chroot inherits the caller's PATH, which on some host distros (Arch)
# omits /sbin and /usr/sbin where apk and most rootfs tooling live.
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Update package index
apk update
# Install Docker and OpenRC
apk add --no-cache \
docker \
docker-openrc \
openrc \
util-linux \
coreutils \
bash \
curl \
ca-certificates
# Configure OpenRC
rc-update add devfs boot
rc-update add procfs boot
rc-update add sysfs boot
rc-update add cgroups boot
rc-update add networking boot
rc-update add docker default
echo "Docker version: $(docker --version)"
SETUP_SCRIPT
chmod +x rootfs/tmp/setup.sh
sudo chroot rootfs /tmp/setup.sh
sudo rm rootfs/tmp/setup.sh
# Configure Docker daemon for OpenRC
# All files are root-owned after chroot, so use sudo for all writes
sudo mkdir -p rootfs/etc/docker rootfs/etc/conf.d
# OpenRC configuration for Docker - enable TCP listener
sudo tee rootfs/etc/conf.d/docker > /dev/null << 'EOF'
# Docker daemon options for OpenRC
DOCKER_OPTS="-H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375"
EOF
# Docker daemon JSON config (storage settings only, hosts in DOCKER_OPTS)
sudo tee rootfs/etc/docker/daemon.json > /dev/null << 'EOF'
{
"storage-driver": "overlay2",
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
EOF
# Configure networking (DHCP)
sudo tee rootfs/etc/network/interfaces > /dev/null << 'EOF'
auto lo
iface lo inet loopback
auto eth0
iface eth0 inet dhcp
EOF
# Set hostname
echo "container-runtime" | sudo tee rootfs/etc/hostname > /dev/null
# Enable root login without password (for debugging)
sudo sed -i 's/root:!:/root::/' rootfs/etc/shadow
# Create ext4 filesystem directly from rootfs directory (matching dev script)
# This is more reliable than mount/copy/umount approach
sudo mkfs.ext4 -L container-runtime -d rootfs -E lazy_itable_init=0,lazy_journal_init=0 container-runtime.ext4 2200M
# Move to images directory
mv container-runtime.ext4 ../images/
# Cleanup
cd ..
sudo rm -rf "$WORK_DIR"
ls -lh images/container-runtime.ext4
- name: Compress large images
run: |
# Compress larger images (>100MB) to reduce upload size
cd images
for img in ubuntu-24.04-minimal.ext4 node-runtime.ext4 python-runtime.ext4 bun-runtime.ext4 container-runtime.ext4; do
if [ -f "$img" ]; then
echo "Compressing $img..."
gzip -9 -k "$img"
ls -lh "${img}.gz"
fi
done
echo "All images:"
ls -lh
- name: Upload images as artifacts
uses: actions/upload-artifact@v4
with:
name: base-images
path: |
images/vmlinux-5.10.fc.bin
images/alpine-3.18-minimal.ext4
images/busybox-1.35.ext4
images/ubuntu-24.04-minimal.ext4.gz
images/node-runtime.ext4.gz
images/python-runtime.ext4.gz
images/bun-runtime.ext4.gz
images/container-runtime.ext4.gz
retention-days: 1
build-ui:
name: Build UI
needs: ci-gate
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install pnpm
run: npm install -g pnpm
- name: Cache pnpm
uses: actions/cache@v4
with:
path: |
~/.pnpm-store
apps/ui/node_modules
key: ${{ runner.os }}-pnpm-${{ hashFiles('apps/ui/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install UI dependencies
working-directory: apps/ui
run: pnpm install --frozen-lockfile
- name: Build UI
working-directory: apps/ui
run: |
# Remove .env to prevent baking development API URLs into production build
rm -f .env .env.local .env.production .env.development
pnpm build
- name: Package UI
run: |
cd apps/ui
# Verify standalone build exists
if [[ ! -f .next/standalone/apps/ui/server.js ]]; then
echo "ERROR: Standalone build not found at .next/standalone/apps/ui/server.js"
echo "Make sure next.config.mjs has output: 'standalone'"
exit 1
fi
# Create a temporary directory to assemble the tarball contents
TEMP_DIR=$(mktemp -d)
# Copy standalone build output (includes server.js, .next/, node_modules/, package.json)
cp -r .next/standalone/apps/ui/. "$TEMP_DIR/"
# Remove any .env files that might have been included in standalone
rm -f "$TEMP_DIR/.env" "$TEMP_DIR/.env.local" "$TEMP_DIR/.env.production"
# Copy static assets from build root to standalone's .next/static
# The standalone .next/static might be incomplete, so merge from build root
if [[ -d .next/static ]]; then
mkdir -p "$TEMP_DIR/.next/static"
cp -r .next/static/. "$TEMP_DIR/.next/static/"
fi
# Copy public directory (Next.js standalone doesn't include this by default)
if [[ -d public ]]; then
cp -r public "$TEMP_DIR/"
fi
# Create tarball from the assembled directory
tar czf ../../nqrust-ui.tar.gz -C "$TEMP_DIR" .
# Cleanup
rm -rf "$TEMP_DIR"
echo "UI tarball created successfully"
tar tzf ../../nqrust-ui.tar.gz | head -20
- name: Upload UI as artifact
uses: actions/upload-artifact@v4
with:
name: ui-build
path: nqrust-ui.tar.gz
retention-days: 1
create-release:
name: Create GitHub Release
needs: [build-binaries, build-ui, build-images]
# build-images is skipped on prerelease tags (alpha/beta/rc) — `always()`
# together with `result != 'failure'` lets us proceed when build-images
# is `skipped` while still blocking on actual failures.
if: ${{ always() && needs.build-binaries.result == 'success' && needs.build-ui.result == 'success' && needs.build-images.result != 'failure' }}
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/
- name: Decompress large images (except container-runtime which exceeds 2GB limit)
# build-images job is skipped for alpha/beta/rc tags, so the
# base-images artifact won't exist for those releases.
if: ${{ needs.build-images.result == 'success' }}
run: |
cd artifacts/base-images
for gz in *.gz; do
if [ -f "$gz" ]; then
# Skip container-runtime - it's >2GB uncompressed, keep it gzipped
if [ "$gz" = "container-runtime.ext4.gz" ]; then
echo "Keeping $gz compressed (>2GB uncompressed)"
continue
fi
gunzip -k "$gz"
fi
done
ls -lh
- name: Package installer scripts
run: |
tar czf nqrust-installer.tar.gz \
scripts/install/install.sh \
scripts/install/uninstall.sh \
scripts/install/README.md \
scripts/install/lib/ \
scripts/install/systemd/ \
scripts/install/sudoers.d/
- name: Organize release files
run: |
mkdir -p release/
find artifacts/ -type f -name "nqrust-*" -exec cp {} release/ \;
find artifacts/ -type f -name "nqr-installer-*" -exec cp {} release/ \;
cp nqrust-installer.tar.gz release/
# Base images are only present when build-images ran. Skip the
# copy when it didn't (alpha/beta/rc tags). Operators installing
# a prerelease pull images from the prior stable release URL.
if [ -d artifacts/base-images ]; then
cp artifacts/base-images/vmlinux-5.10.fc.bin release/
cp artifacts/base-images/alpine-3.18-minimal.ext4 release/
cp artifacts/base-images/busybox-1.35.ext4 release/
cp artifacts/base-images/ubuntu-24.04-minimal.ext4 release/
cp artifacts/base-images/node-runtime.ext4 release/
cp artifacts/base-images/python-runtime.ext4 release/
cp artifacts/base-images/bun-runtime.ext4 release/
# Container runtime stays compressed (>2GB uncompressed exceeds GitHub limit)
cp artifacts/base-images/container-runtime.ext4.gz release/
else
echo "::notice::No base-images artifact (prerelease build) — release will not include kernel/rootfs files."
fi
ls -lh release/
- name: Generate checksums
working-directory: release
run: |
sha256sum nqrust-manager-* > checksums.txt
sha256sum nqrust-agent-* >> checksums.txt
sha256sum nqrust-guest-agent-* >> checksums.txt
sha256sum nqr-installer-* >> checksums.txt
sha256sum nqrust-ui.tar.gz >> checksums.txt
sha256sum nqrust-installer.tar.gz >> checksums.txt
# Image artifacts are absent on prerelease builds — every line below
# is a no-op then.
sha256sum vmlinux-*.bin >> checksums.txt 2>/dev/null || true
sha256sum *.ext4 >> checksums.txt 2>/dev/null || true
sha256sum *.ext4.gz >> checksums.txt 2>/dev/null || true
cat checksums.txt
- name: Get version
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.version }}"
else
VERSION="${GITHUB_REF#refs/tags/}"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Pre-release only when the tag explicitly says so (-alpha, -beta, -rc).
# We dropped the blanket "v0.x.x is always prerelease" rule because
# 0.x is the project's normal range — it shouldn't permanently
# disqualify everything from being marked Latest on GitHub.
if [[ "$VERSION" =~ -(alpha|beta|rc) ]]; then
echo "prerelease=true" >> $GITHUB_OUTPUT
else
echo "prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Create release manifest
working-directory: release
run: |
cat > release-manifest.json <<EOF
{
"version": "${{ steps.version.outputs.version }}",
"released_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"min_compatible_version": "v0.1.0",
"changelog": "See CHANGELOG.md for details",
"components": {
"manager": {
"version": "${{ steps.version.outputs.version }}",
"url": "https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/nqrust-manager-x86_64-linux-musl",
"checksum": "$(sha256sum nqrust-manager-x86_64-linux-musl | cut -d' ' -f1)",
"size": $(stat -f%z nqrust-manager-x86_64-linux-musl 2>/dev/null || stat -c%s nqrust-manager-x86_64-linux-musl)
},
"agent": {
"version": "${{ steps.version.outputs.version }}",
"url": "https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/nqrust-agent-x86_64-linux-musl",
"checksum": "$(sha256sum nqrust-agent-x86_64-linux-musl | cut -d' ' -f1)",
"size": $(stat -f%z nqrust-agent-x86_64-linux-musl 2>/dev/null || stat -c%s nqrust-agent-x86_64-linux-musl)
},
"guest-agent": {
"version": "${{ steps.version.outputs.version }}",
"url": "https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/nqrust-guest-agent-x86_64-linux-musl",
"checksum": "$(sha256sum nqrust-guest-agent-x86_64-linux-musl | cut -d' ' -f1)",
"size": $(stat -f%z nqrust-guest-agent-x86_64-linux-musl 2>/dev/null || stat -c%s nqrust-guest-agent-x86_64-linux-musl)
},
"ui": {
"version": "${{ steps.version.outputs.version }}",
"url": "https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/nqrust-ui.tar.gz",
"checksum": "$(sha256sum nqrust-ui.tar.gz | cut -d' ' -f1)",
"size": $(stat -f%z nqrust-ui.tar.gz 2>/dev/null || stat -c%s nqrust-ui.tar.gz)
},
"installer": {
"version": "${{ steps.version.outputs.version }}",
"url": "https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/nqrust-installer.tar.gz",
"checksum": "$(sha256sum nqrust-installer.tar.gz | cut -d' ' -f1)",
"size": $(stat -f%z nqrust-installer.tar.gz 2>/dev/null || stat -c%s nqrust-installer.tar.gz)
}
},
"migrations": {
"from": "v0.1.0",
"requires_downtime": false,
"breaking_changes": false
}
}
EOF
cat release-manifest.json
jq '.' release-manifest.json # Validate JSON
- name: Generate release notes
id: release_notes
run: |
cat > release_notes.md <<'EOF'
## NQRust-MicroVM ${{ steps.version.outputs.version }}
### Installation
**Quick Install (Ubuntu 22.04+):**
```bash
curl -fsSL https://raw.githubusercontent.com/${{ github.repository }}/main/scripts/install/install.sh | bash
```
**Manual Install:**
```bash
# Download installer
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/nqr-installer-x86_64-linux-musl
chmod +x nqr-installer-x86_64-linux-musl
# Run installer (interactive TUI)
sudo ./nqr-installer-x86_64-linux-musl install
# Or with options
sudo ./nqr-installer-x86_64-linux-musl install --mode production --yes
```
### Pre-built Binaries
Download and install manually:
- **Installer (Rust TUI)**: [nqr-installer-x86_64-linux-musl](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/nqr-installer-x86_64-linux-musl) (Recommended)
- Manager: [nqrust-manager-x86_64-linux-musl](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/nqrust-manager-x86_64-linux-musl)
- Agent: [nqrust-agent-x86_64-linux-musl](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/nqrust-agent-x86_64-linux-musl)
- Guest Agent: [nqrust-guest-agent-x86_64-linux-musl](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/nqrust-guest-agent-x86_64-linux-musl)
- UI: [nqrust-ui.tar.gz](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/nqrust-ui.tar.gz)
- Legacy Installer Scripts: [nqrust-installer.tar.gz](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/nqrust-installer.tar.gz)
### Base Images (for VMs and Functions)
The installer downloads these automatically, or download manually:
- **Kernel**: [vmlinux-5.10.fc.bin](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/vmlinux-5.10.fc.bin) - Firecracker kernel 5.10
- **Alpine**: [alpine-3.18-minimal.ext4](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/alpine-3.18-minimal.ext4) - Alpine Linux 3.18 minimal
- **BusyBox**: [busybox-1.35.ext4](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/busybox-1.35.ext4) - BusyBox 1.35
- **Ubuntu**: [ubuntu-24.04-minimal.ext4](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/ubuntu-24.04-minimal.ext4) - Ubuntu 24.04 minimal
- **Node.js Runtime**: [node-runtime.ext4](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/node-runtime.ext4) - For serverless functions
- **Python Runtime**: [python-runtime.ext4](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/python-runtime.ext4) - For serverless functions
- **Bun Runtime**: [bun-runtime.ext4](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/bun-runtime.ext4) - For serverless functions (TypeScript/JS)
- **Container Runtime**: [container-runtime.ext4.gz](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/container-runtime.ext4.gz) - Docker-in-VM (~2.2GB, gzipped)
### Air-Gapped Installation
For servers without internet access, download the self-extracting bundle (built automatically after release):
- **Air-Gapped Bundle**: [nqr-microvm-airgap-${{ steps.version.outputs.version }}.run](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/nqr-microvm-airgap-${{ steps.version.outputs.version }}.run) (~3-5GB)
```bash
chmod +x nqr-microvm-airgap-${{ steps.version.outputs.version }}.run
sudo ./nqr-microvm-airgap-${{ steps.version.outputs.version }}.run
```
The bundle includes all binaries, images, .deb packages, Node.js, and the web UI.
### Checksums
```
$(cat release/checksums.txt)
```
### Requirements
- Ubuntu 22.04+ or Debian 11+ or RHEL 8+
- x86_64 CPU with KVM support
- 2GB+ RAM
- 20GB+ disk space
### What's New
- See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details
### Documentation
- [Installation Guide](https://github.com/${{ github.repository }}/blob/main/README.md)
- [Quick Start](https://github.com/${{ github.repository }}/blob/main/RUN.md)
- [Features](https://github.com/${{ github.repository }}/blob/main/FEATURES.md)
EOF
echo "notes_file=release_notes.md" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.version.outputs.version }}
name: Release ${{ steps.version.outputs.version }}
body_path: ${{ steps.release_notes.outputs.notes_file }}
draft: false
prerelease: ${{ steps.version.outputs.prerelease }}
files: |
release/nqrust-manager-x86_64-linux-musl
release/nqrust-agent-x86_64-linux-musl
release/nqrust-guest-agent-x86_64-linux-musl
release/nqr-installer-x86_64-linux-musl
release/nqrust-ui.tar.gz
release/nqrust-installer.tar.gz
release/checksums.txt
release/release-manifest.json
release/vmlinux-5.10.fc.bin
release/alpine-3.18-minimal.ext4
release/busybox-1.35.ext4
release/ubuntu-24.04-minimal.ext4
release/node-runtime.ext4
release/python-runtime.ext4
release/bun-runtime.ext4
release/container-runtime.ext4.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release manifest as latest
uses: softprops/action-gh-release@v1
with:
tag_name: latest
name: Latest Release
body: "Latest release manifest for auto-updater"
draft: false
prerelease: false
files: release/release-manifest.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# TODO: Re-enable installer tests in v0.1.1 after fixing disk space and permission issues
# test-installer:
# name: Test Installer
# needs: create-release
# runs-on: ubuntu-24.04
# strategy:
# matrix:
# mode: [production, dev, manager, agent]
#
# steps:
# - name: Checkout code
# uses: actions/checkout@v4
#
# - name: Test installer (${{ matrix.mode }} mode)
# run: |
# # Test in non-interactive mode (installer will use sudo internally)
# # Use /tmp for logs to avoid permission issues in CI
# LOG_DIR=/tmp/nqrust-install bash scripts/install/install.sh \
# --mode ${{ matrix.mode }} \
# --non-interactive \
# --network-mode nat
#
# - name: Verify services
# if: matrix.mode != 'agent'
# run: |
# systemctl is-active nqrust-manager
# curl -f http://localhost:18080/health
#
# - name: Verify agent
# if: matrix.mode != 'manager'
# run: |
# systemctl is-active nqrust-agent
# curl -f http://localhost:9090/health
#
# - name: Show logs on failure
# if: failure()
# run: |
# journalctl -u nqrust-manager -n 100 --no-pager || true
# journalctl -u nqrust-agent -n 100 --no-pager || true
#
# - name: Test uninstaller
# run: |
# bash scripts/install/uninstall.sh \
# --force \
# --remove-all