feat: ship agent onboarding runtime
This commit is contained in:
Executable
+422
@@ -0,0 +1,422 @@
|
||||
#!/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."
|
||||
Reference in New Issue
Block a user