423 lines
15 KiB
Bash
Executable File
423 lines
15 KiB
Bash
Executable File
#!/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 <target>/releases/<release-id> and repoint
|
|
# <target>/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 <host> <release-id> [options]
|
|
|
|
Build @code/agents, package a release bundle, upload it to the VDS, and
|
|
atomically promote it so <target>/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
|
|
(<target>/releases/<release-id>). 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 <target>/releases/<release-id> and <target>/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 <target>/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: <host> <release-id>)"
|
|
fi
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# Extra positionals after "--" are not supported.
|
|
[ $# -eq 0 ] || die "unexpected extra arguments: $* (usage: <host> <release-id>)"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validate ALL arguments before any side-effect command.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
[ -n "$host" ] || die "host is required (usage: <host> <release-id>)."
|
|
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. <target>/.staging is kept on the same filesystem as
|
|
# <target>/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 <bundle> $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 <target> on the same filesystem, so the rename-based promotion is
|
|
# atomic. The running service is never disturbed: it keeps serving via the old
|
|
# <target>/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 <target>/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 <<REMOTE_DRY
|
|
[dry-run] remote promote (ssh $host sh -s -- '$release_id' '$target' '$pnpm_ver'):
|
|
mkdir -p $staging_root $releases_dir
|
|
rm -rf $remote_stage && mkdir -p $remote_stage
|
|
tar -xzf $remote_tarball -C $remote_stage
|
|
(corepack enable && corepack prepare pnpm@$pnpm_ver --activate) # if pnpm missing
|
|
verify packages/agents/dist/server.mjs and deploy/vds/zopu-agents.service
|
|
fail if $remote_release already exists (release ids are immutable)
|
|
mv $remote_stage $remote_release
|
|
REMOTE_DRY
|
|
fi
|
|
|
|
echo "release-agents: done."
|