#!/bin/sh # scripts/release-agents.sh # # Build @code/agents locally, package a release bundle (excluding secrets and # development-only workspace clutter), upload it to a staging directory on the # VDS, then atomically promote it to /releases/ and repoint # /current at it. # # Why deps are installed on the target, not shipped from the build host: # The runtime tree contains platform-specific native binaries # (@rivetkit/rivetkit-napi, @rivet-dev/agentos-sidecar, # @rivet-dev/agentos-runtime-sidecar, @rivetkit/engine-cli), selected by # os/cpu. The build host (macOS arm64) resolves the *-darwin-arm64 variants; # the Linux VDS needs the *-linux-* variants. The pnpm lockfile records every # platform variant, so a frozen-lockfile `pnpm install --prod` on the target # fetches the correct architecture. Shipping the local node_modules would # deliver wrong-architecture binaries. The built JS bundles under dist/ are # platform-independent. # # Documentation lives in the usage text below; do not edit other docs. set -eu # --------------------------------------------------------------------------- # Usage # --------------------------------------------------------------------------- usage() { cat <<'EOF' Usage: scripts/release-agents.sh [options] Build @code/agents, package a release bundle, upload it to the VDS, and atomically promote it so /current points at the new release. Positional arguments (required): host SSH destination for the VDS, passed verbatim to ssh/scp. May be "user@host", an alias from ~/.ssh/config, or a bare host. release-id Opaque release identifier. Names the release directory (/releases/). Must match [A-Za-z0-9][A-Za-z0-9._-]*. Options: --target DIR Base directory on the VDS (default: /srv/zopu). Must be absolute and non-empty. The release is promoted to /releases/ and /current is repointed at it. --skip-build Use the existing packages/agents/dist instead of rebuilding. --dry-run Validate arguments and print every command that would run, but perform no build, packaging, upload, or promotion. -h, --help Show this help and exit. Environment: Builds run via pnpm (the workspace's package manager). The VDS promote step uses corepack (shipped with Node) to activate pnpm if it is not already installed; otherwise an existing global pnpm is used. Examples: scripts/release-agents.sh zopu-vds 2026-08-04-a1b2c3 scripts/release-agents.sh deploy@10.0.0.5 v1.2.0 --target /srv/zopu scripts/release-agents.sh zopu-vds 2026-08-04-a1b2c3 --dry-run Exit status: 0 release promoted successfully (or dry-run completed) 1 invalid arguments, missing prerequisites, or a step failed EOF } # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- die() { echo "release-agents: ERROR: $*" >&2 exit 1 } # Repository root detection. This script must run from the repo root so the # workspace manifests and package sources resolve relative to the cwd. require_repo_root() { [ -f "./pnpm-workspace.yaml" ] || die "must run from the repository root (pnpm-workspace.yaml not found in $(pwd))." [ -f "./packages/agents/package.json" ] || die "packages/agents/package.json not found; run from the repository root." [ -f "./pnpm-lock.yaml" ] || die "pnpm-lock.yaml not found; run from the repository root after install." } # Validate the release id: a single path segment with no traversal/escape chars. # It becomes a directory name under /releases/, so reject anything that # could escape or alter the path. validate_release_id() { release_id=$1 [ -n "$release_id" ] || die "release-id is required." # Positive whitelist: start with alnum, then alnum/dot/dash/underscore. if ! expr "$release_id" : '[A-Za-z0-9][A-Za-z0-9._-]*$' >/dev/null 2>&1; then die "release-id must match [A-Za-z0-9][A-Za-z0-9._-]* (got: $release_id)." fi # Defense in depth: reject literal traversal even though the whitelist above # already excludes '/' and '..'. case "$release_id" in *..* | */* | *\** | *' '* | *'~'*) die "release-id must not contain '..', '/', spaces, '*', or '~' (got: $release_id)." ;; esac } # Validate the target base directory: absolute and non-empty. validate_target() { target=$1 [ -n "$target" ] || die "--target must be a non-empty absolute path." case "$target" in /*) ;; *) die "--target must be an absolute path (got: $target)." ;; esac # Reject traversal segments. case "$target" in *'/../'* | *'/..' | *'~'* | *'*'*) die "--target must not contain path traversal or glob characters (got: $target)." ;; esac } # Emit a trimmed pnpm-workspace.yaml containing the runtime packages plus every # manifest named by the root workspace's dependency graph. Production install # does not materialize root devDependencies, but pnpm still resolves their # workspace locators against this manifest set. emit_trimmed_workspace() { awk ' # A top-level key begins at column 0. /^[A-Za-z_-][A-Za-z0-9_-]*:/ { key = $0 sub(/:.*/, "", key) if (key == "packages") { print "packages:" print " - packages/agents" print " - packages/backend" print " - packages/config" print " - packages/env" print " - packages/primitives" print "" skip = 1 next } skip = 0 } { if (!skip) print } ' ./pnpm-workspace.yaml } # --------------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------------- host="" release_id="" target="/srv/zopu" skip_build=0 dry_run=0 while [ $# -gt 0 ]; do case "$1" in -h | --help) usage exit 0 ;; --target) [ $# -ge 2 ] || die "--target requires a value." target=$2 shift 2 ;; --target=*) target=${1#--target=} shift ;; --skip-build) skip_build=1 shift ;; --dry-run) dry_run=1 shift ;; --) shift break ;; -*) die "unknown option: $1 (see --help)" ;; *) if [ -z "$host" ]; then host=$1 elif [ -z "$release_id" ]; then release_id=$1 else die "unexpected extra argument: $1 (usage: )" fi shift ;; esac done # Extra positionals after "--" are not supported. [ $# -eq 0 ] || die "unexpected extra arguments: $* (usage: )" # --------------------------------------------------------------------------- # Validate ALL arguments before any side-effect command. # --------------------------------------------------------------------------- [ -n "$host" ] || die "host is required (usage: )." validate_release_id "$release_id" validate_target "$target" require_repo_root # pnpm version: derive from the root packageManager field (single source of # truth). Fall back to a pinned default if the field is absent. pnpm_ver=$(sed -n 's/.*"packageManager"[[:space:]]*:[[:space:]]*"pnpm@\([^"]*\)".*/\1/p' ./package.json) [ -n "$pnpm_ver" ] || pnpm_ver="11.17.0" command -v pnpm >/dev/null 2>&1 || die "pnpm is required on the build host (found via corepack or PATH)." command -v tar >/dev/null 2>&1 || die "tar is required on the build host." command -v ssh >/dev/null 2>&1 || die "ssh is required on the build host." command -v scp >/dev/null 2>&1 || die "scp is required on the build host." # Derived remote paths. /.staging is kept on the same filesystem as # /releases so the final promotion is an atomic rename. staging_root="$target/.staging" releases_dir="$target/releases" remote_tarball="$staging_root/$release_id.tar.gz" remote_stage="$staging_root/$release_id" remote_release="$releases_dir/$release_id" remote_current="$target/current" echo "release-agents: host=$host release-id=$release_id target=$target pnpm=$pnpm_ver" [ "$skip_build" -eq 1 ] && echo "release-agents: --skip-build: using existing dist" [ "$dry_run" -eq 1 ] && echo "release-agents: --dry-run: no commands will be executed" # --------------------------------------------------------------------------- # Build # --------------------------------------------------------------------------- if [ "$dry_run" -eq 0 ]; then if [ "$skip_build" -eq 0 ]; then echo "release-agents: installing workspace dependencies (frozen lockfile)" pnpm install --frozen-lockfile echo "release-agents: building @code/agents" pnpm --filter @code/agents build fi [ -f "./packages/agents/dist/server.mjs" ] || die "build output missing: packages/agents/dist/server.mjs (run without --skip-build)." else if [ "$skip_build" -eq 0 ]; then echo "[dry-run] pnpm install --frozen-lockfile" echo "[dry-run] pnpm --filter @code/agents build" fi echo "[dry-run] verify: packages/agents/dist/server.mjs" fi # --------------------------------------------------------------------------- # Package the release bundle # --------------------------------------------------------------------------- # Curated allowlist: root manifests required for pnpm workspace resolution, # the built agents output, and runtime source required by Flue's bundled # server. @code/env resolves from TypeScript source at runtime. The backend and # primitives are manifests only: root devDependencies name them but production # installation does not materialize them. No unrelated package sources, # node_modules, .git, repos/, apps/, docs/, or .env* files are shipped. bundle_files="package.json pnpm-lock.yaml .npmrc \ packages/agents/package.json \ packages/backend/package.json \ packages/config/package.json \ packages/env/package.json \ packages/primitives/package.json" bundle_dirs="packages/agents/dist packages/env/src deploy/vds" if [ "$dry_run" -eq 0 ]; then stage_dir=$(mktemp -d 2>/dev/null || mktemp -d -t release-agents) trap 'rm -rf "$stage_dir"' EXIT INT TERM mkdir -p "$stage_dir/packages/agents" "$stage_dir/packages/backend" "$stage_dir/packages/config" "$stage_dir/packages/env" "$stage_dir/packages/primitives" for f in $bundle_files; do [ -f "./$f" ] || die "required file not found: $f" cp "./$f" "$stage_dir/$f" done for d in $bundle_dirs; do [ -d "./$d" ] || die "required directory not found: $d" mkdir -p "$(dirname "$stage_dir/$d")" cp -R "./$d" "$stage_dir/$d" done # Trimmed workspace manifest scoped to the release subset. emit_trimmed_workspace >"$stage_dir/pnpm-workspace.yaml" # Refuse to ship any environment file. Belt-and-suspenders: the allowlist # above cannot include one, but assert it on the packed tarball. tarball="$stage_dir/$release_id.tar.gz" # tar into a path inside the tree is fine; exclude the output itself. tar -czf "$tarball" -C "$stage_dir" \ --exclude="$release_id.tar.gz" \ package.json pnpm-lock.yaml .npmrc pnpm-workspace.yaml packages deploy if tar -tzf "$tarball" | grep -Eq '(^|/)\.env($|\.)'; then die "refusing to ship release: environment file detected in bundle." fi echo "release-agents: packaged $(tar -tzf "$tarball" | wc -l | tr -d ' ') files" else echo "[dry-run] would package allowlist: $bundle_files $bundle_dirs + trimmed pnpm-workspace.yaml" echo "[dry-run] would assert: no .env in bundle" fi # --------------------------------------------------------------------------- # Upload to staging # --------------------------------------------------------------------------- if [ "$dry_run" -eq 0 ]; then echo "release-agents: uploading to $host:$remote_tarball" ssh "$host" "mkdir -p '$staging_root'" scp -q "$tarball" "$host:$remote_tarball" else echo "[dry-run] ssh $host mkdir -p '$staging_root'" echo "[dry-run] scp -q $host:$remote_tarball" fi # --------------------------------------------------------------------------- # Promote on the VDS (remote) # --------------------------------------------------------------------------- # The remote script runs with set -eu. It receives release-id, target, and pnpm # version as positional arguments so no local values are interpolated into the # heredoc (the 'REMOTE' delimiter is quoted). Staging, releases, and current all # live under on the same filesystem, so the rename-based promotion is # atomic. The running service is never disturbed: it keeps serving via the old # /current symlink until the final atomic rename repoints it. if [ "$dry_run" -eq 0 ]; then echo "release-agents: promoting $release_id on $host" ssh "$host" "sh -s -- '$release_id' '$target' '$pnpm_ver'" <<'REMOTE' set -eu release_id=$1 target=$2 pnpm_ver=$3 staging_root="$target/.staging" releases_dir="$target/releases" stage="$staging_root/$release_id" release="$releases_dir/$release_id" current="$target/current" tarball="$staging_root/$release_id.tar.gz" echo "[remote] preparing $release under $target" mkdir -p "$staging_root" "$releases_dir" # Release ids are immutable. Refuse replacement rather than deleting a release # that may still back `current` or be needed for rollback. if [ -e "$release" ]; then echo "[remote] ERROR: release already exists: $release" >&2 exit 1 fi # Fresh staging directory. rm -rf "$stage" mkdir -p "$stage" tar -xzf "$tarball" -C "$stage" rm -f "$tarball" # Ensure pnpm is available. corepack ships with Node; activate pnpm only if a # global pnpm is not already on PATH. if ! command -v pnpm >/dev/null 2>&1; then if ! command -v corepack >/dev/null 2>&1; then echo "[remote] ERROR: neither pnpm nor corepack is available on the VDS." >&2 exit 1 fi corepack enable corepack prepare "pnpm@$pnpm_ver" --activate fi # Materialize production dependencies for THIS platform. The frozen lockfile is # cross-platform (records every os/cpu variant), so the correct native binaries # for the VDS are fetched here. --prod excludes devDependencies. cd "$stage" pnpm install --prod --frozen-lockfile # Sanity: the runtime entry and the deployment artifacts must exist before # promotion. The latter are installed with the release so the unit's Documents= # target remains valid after an atomic switch. if [ ! -f "$stage/packages/agents/dist/server.mjs" ]; then echo "[remote] ERROR: packages/agents/dist/server.mjs missing after install." >&2 exit 1 fi if [ ! -f "$stage/deploy/vds/zopu-agents.service" ]; then echo "[remote] ERROR: deploy/vds/zopu-agents.service missing after install." >&2 exit 1 fi # Atomic promotion into releases/ (rename on the same filesystem). mv "$stage" "$release" # Atomic symlink swap for /current. Create a temporary symlink then # rename it over the existing current (mv -T treats the destination as a file, # not a directory, giving an atomic rename). GNU coreutils (standard on Linux) # provides -T. tmp_link="$current.tmp.$$" ln -s "$release" "$tmp_link" mv -T "$tmp_link" "$current" echo "[remote] promoted: $current -> $release" REMOTE else cat <