Skip to content

feat(viz): cohort swarm grid renders every actor's living-swarm panel… #862

feat(viz): cohort swarm grid renders every actor's living-swarm panel…

feat(viz): cohort swarm grid renders every actor's living-swarm panel… #862

Workflow file for this run

name: Build, Deploy & Publish
on:
push:
branches: [master]
workflow_dispatch:
concurrency:
group: deploy
cancel-in-progress: true
permissions:
contents: write
pages: write
env:
# Node 24 ships npm 11.x which matches the developer-machine npm that
# authors lockfile updates. Pinning Node 22 (npm 10.x) caused the
# lockfile-sync preflight to fail on every push because npm 10 and
# npm 11 disagree on transitive entries (`@tailwindcss/oxide-wasm32-wasi`
# bundled deps) and `peer: true` markers on optional peers.
NODE_VERSION: '24'
# SERVER_IP + APP_DIR resolved from GitHub Actions repo secrets so
# deploy topology isn't encoded in the public workflow file. Set both
# at repo → Settings → Secrets and variables → Actions → Secrets.
SERVER_IP: ${{ secrets.SERVER_IP }}
APP_DIR: ${{ secrets.APP_DIR }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org
# Verify lockfiles are in sync with package.json. Emits ANNOTATIONS
# only (no hard fail) because re-resolving `npm install
# --package-lock-only` on CI's Linux x64 can legitimately produce
# drift against a lockfile authored on macOS arm64 — optional
# platform-specific deps (e.g. `@tailwindcss/oxide-wasm32-wasi`
# bundled children, native `fsevents` vs `@rollup/rollup-linux-*`)
# resolve differently per platform even when package.json hasn't
# changed. The authoritative check is `npm ci` below: it hard-fails
# on REAL desync (missing/added top-level deps) but tolerates
# platform churn. This preflight catches common dependabot-style
# real desync and surfaces it as a GitHub annotation for the PR
# author to fix, without blocking the deploy when only platform
# variance is in play. Recipe was added originally for PR #264,
# #310; kept as warn-only after #312/#313/#314 showed platform
# drift false-positives.
- name: Verify lockfiles are in sync with package.json (warn-only)
run: |
set +e
check() {
local dir="$1"
local label="$2"
pushd "$dir" >/dev/null
npm install --package-lock-only --no-audit --no-fund --ignore-scripts >/dev/null 2>&1
if ! git diff --quiet package-lock.json; then
echo "::warning file=${dir}/package-lock.json::${label} package-lock.json drift detected (may be platform-specific — npm ci will fail if real desync)."
echo "::warning file=${dir}/package-lock.json::To resync: cd ${dir} && npm install --package-lock-only && git add package-lock.json && git commit -m 'chore(deps): sync lockfile' && git push"
echo ""
echo "--- drift in ${dir}/package-lock.json (warn) ---"
git diff package-lock.json | head -40
# Restore the committed lockfile so the subsequent npm ci
# runs against the committed state, not the re-resolved one.
git checkout -- package-lock.json
fi
popd >/dev/null
}
check "." "Root"
check "src/dashboard" "Dashboard"
- name: Guard against pnpm-workspace contamination in lockfiles
run: |
set -e
fail=0
for f in package-lock.json src/dashboard/package-lock.json; do
if [ ! -f "$f" ]; then continue; fi
if grep -q 'node_modules/\.pnpm/' "$f"; then
echo "::error file=$f::Lockfile contains 'node_modules/.pnpm/' substring. This means it was authored from inside a pnpm workspace and references parent paths that do not exist in CI. To repair: copy package.json to a directory outside any pnpm workspace, run 'npm install' there, copy the resulting package-lock.json back, commit, push."
fail=1
fi
done
exit $fail
- name: Install dependencies
run: npm ci
- name: Build TypeScript (engine + runtime)
run: npm run build
- name: Install dashboard dependencies
run: cd src/dashboard && npm ci
- name: Build dashboard
run: npm run dashboard:build
env:
# Vite inlines these at build time as `import.meta.env.VITE_*`.
# Empty values short-circuit Analytics.tsx so the build still
# works without secrets configured (local fork / fresh clone).
VITE_GA_MEASUREMENT_ID: ${{ secrets.GA_MEASUREMENT_ID }}
VITE_CLARITY_PROJECT_ID: ${{ secrets.CLARITY_PROJECT_ID }}
- name: Run tests
run: npm test || true
- name: Check doc examples compile against package
run: npm run check:doc-examples
# The build job's checkout has the dev-floor version (0.9.0) from
# package.json. The publish job auto-bumps to 0.9.${run_number} in
# its own checkout, so the build job never sees the published
# version. typedoc has `includeVersion: true` and reads
# package.json directly, which is why early v0.8 deploys advertised
# "v0.8.0" while npm shipped 0.8.${prev_run_number}.
#
# Query the npm registry for the latest published version and use
# it for typedoc. Most commits are UI-only (publish skipped) — for
# these, docs match npm exactly. Library commits drift docs behind
# by exactly one publish (build runs npm view BEFORE publish bumps
# the patch), and the next workflow run converges. Falling back to
# the run-number form when npm is unreachable so the docs deploy
# never produces a literal "0.9.0" again.
- name: Sync version for docs
run: |
LATEST=$(npm view paracosm version 2>/dev/null || true)
if [ -z "$LATEST" ]; then
LATEST="0.9.${{ github.run_number }}"
echo "npm view failed; falling back to ${LATEST}"
fi
node -e "const p=require('./package.json'); p.version='${LATEST}'; require('fs').writeFileSync('./package.json', JSON.stringify(p, null, 2) + '\n')"
echo "Docs will report version: ${LATEST}"
- name: Build API docs (TypeDoc)
run: |
npm run docs
cp assets/docs-theme/paracosm-override.css docs/api/assets/paracosm-override.css
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build
path: |
dist/
src/dashboard/dist/
docs/api/
assets/
scenarios/
scripts/
package.json
package-lock.json
src/
config/
typedoc.json
tsconfig.json
tsconfig.build.json
.env.example
retention-days: 1
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
name: build
path: build/
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H ${{ env.SERVER_IP }} >> ~/.ssh/known_hosts
- name: Deploy to Linode
run: |
SSH="ssh -i ~/.ssh/deploy_key root@${{ env.SERVER_IP }}"
# Sync build to server.
#
# --delete removes paths on the server that aren't in the
# artifact so old JS bundles + orphaned docs clean up on
# each deploy. The excludes below are paths that DO NOT
# belong in the artifact but MUST survive a deploy:
# .env — hand-authored, holds API keys + caps.
# node_modules — installed separately via npm ci.
# data/ — SQLite session store for cached runs.
# Without this exclude, every deploy
# wipes /opt/paracosm/data/sessions.db
# because rsync sees it as "not in
# source → delete it." The server
# creates data/ on first boot and
# owns its lifecycle from there.
# .event-buffer.json — transient SSE event buffer, written
# per-run so the dashboard can rehydrate
# after a restart. Regenerable.
# logs/ — runtime logs (pm2 stdout/stderr).
# output/ — RunArtifact JSONs persisted per
# completed sim. data/runs.db points
# at these files via record.artifactPath;
# without this exclude, every deploy
# wiped output/*.json and Library tab
# drawers showed "Artifact file
# unreadable" 410 errors for every run
# that survived in runs.db across the
# deploy. The server's run-artifact
# writer owns the file lifecycle.
rsync -azP --delete \
--exclude='.env' \
--exclude='node_modules' \
--exclude='data/' \
--exclude='.event-buffer.json' \
--exclude='logs/' \
--exclude='output/' \
-e "ssh -i ~/.ssh/deploy_key" \
build/ root@${{ env.SERVER_IP }}:${{ env.APP_DIR }}/
# Install production deps and restart
$SSH << 'REMOTE'
set -e
cd /opt/paracosm
# Install production dependencies
npm ci --omit=dev 2>/dev/null || npm install --omit=dev
# Restart with pm2
pm2 delete paracosm 2>/dev/null || true
pm2 start "npx tsx src/cli/serve.ts" \
--name paracosm \
--max-memory-restart 3G \
--env production
pm2 save
echo "Paracosm deployed and running"
REMOTE
docs:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
name: build
path: build/
- name: Deploy API docs to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: build/docs/api
cname: docs.agentos.sh
publish:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v4
with:
# Full history needed so scripts/generate-changelog.mjs can walk
# every package.json version boundary. Prior requirement was
# only the previous-commit diff (fetch-depth: 2) for library-
# change detection.
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org
# Skip the rest of the publish job when only the dashboard, docs,
# CI workflows, or other non-library files changed. The npm package
# ships dist/engine, dist/runtime, and dist/cli (server bits used by
# `npm run dashboard`). Dashboard React code is bundled at build time
# but not part of the public TS API surface — UI-only commits should
# NOT cut a new package version (was producing a release per UI tweak).
- name: Detect library changes
id: changes
run: |
# If we don't have a previous commit (initial push), publish.
if ! git rev-parse HEAD~1 >/dev/null 2>&1; then
echo "library_changed=true" >> "$GITHUB_OUTPUT"
echo "First commit — proceeding with publish."
exit 0
fi
CHANGED=$(git diff --name-only HEAD~1 HEAD)
echo "Changed files:"
echo "$CHANGED"
# Files that affect the published package surface
if echo "$CHANGED" | grep -qE '^(src/engine/|src/runtime/|src/cli/(serve|server-app|sim-config|cli-run-options|compile|run|run-a|run-b|pair-runner|custom-scenarios|types)\.ts|src/cli/(serve|server-app|sim-config|cli-run-options|compile|run|run-a|run-b|pair-runner|custom-scenarios|types)/|package\.json|tsconfig\.build\.json|README\.md|LICENSE)'; then
echo "library_changed=true" >> "$GITHUB_OUTPUT"
echo "Library code changed — will publish."
else
echo "library_changed=false" >> "$GITHUB_OUTPUT"
echo "Only dashboard/docs/CI changed — skipping npm publish + GitHub release."
fi
- name: Install dependencies
if: steps.changes.outputs.library_changed == 'true'
run: npm ci
- name: Build
if: steps.changes.outputs.library_changed == 'true'
run: npm run build
- name: Auto-version from run number
if: steps.changes.outputs.library_changed == 'true'
id: version
run: |
# Use major.minor from package.json + GitHub run number as patch
# Always unique, never collides, no commit-back needed
CURRENT=$(node -p "require('./package.json').version")
IFS='.' read -r MAJOR MINOR _ <<< "$CURRENT"
NEXT="${MAJOR}.${MINOR}.${{ github.run_number }}"
node -e "const p=require('./package.json'); p.version='${NEXT}'; require('fs').writeFileSync('./package.json', JSON.stringify(p, null, 2) + '\n')"
echo "version=$NEXT" >> $GITHUB_OUTPUT
echo "Publishing ${NEXT} (run #${{ github.run_number }})"
- name: Generate CHANGELOG and release notes
if: steps.changes.outputs.library_changed == 'true'
run: node scripts/generate-changelog.mjs
- name: Commit CHANGELOG if changed
if: steps.changes.outputs.library_changed == 'true'
run: |
if ! git diff --quiet CHANGELOG.md; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md
git commit -m "chore: update CHANGELOG"
git push origin HEAD:master
else
echo "CHANGELOG unchanged; no commit-back."
fi
- name: Publish to npm
if: steps.changes.outputs.library_changed == 'true'
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub Release
if: steps.changes.outputs.library_changed == 'true'
run: |
gh release create "v${{ steps.version.outputs.version }}" \
--title "v${{ steps.version.outputs.version }}" \
--notes-file release-notes.md \
--latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}