cleanup #941
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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|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 }} |