Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 7 additions & 18 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Attach a deployable tarball to a GitHub release.
# The archive includes node_modules, the design-system submodule, and the
# built client (public/app.bundle.js) so a new environment can unpack,
# add a local .env, and start with `node server.js`.
# `npm run pack` builds a minified client + single-file server bundle (no
# node_modules) into dist.tar.gz. Unpack, add a local .env (and optionally
# session.config.json), then start with `node server.js`.

name: Build and Release

Expand Down Expand Up @@ -32,22 +32,11 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Build frontend bundle
run: npm run build
- name: Run tests
run: npm test

- name: Archive release package
run: |
tar -czf /tmp/dist.tar.gz \
--exclude='.git' \
--exclude='.github' \
--exclude='.env' \
--exclude='session.config.json' \
--exclude='eval-session.json' \
--exclude='chat-sessions.json' \
--exclude='chat-config.json' \
--exclude='public/app.js' \
.
mv /tmp/dist.tar.gz dist.tar.gz
- name: Pack release
run: npm run pack

- name: Upload build artifact (for workflow logs)
uses: actions/upload-artifact@v7
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ session.config.json
eval-session.json
.codesignal/report.md
.agent-build
dist
dist.tar.gz

# Local course context for the agent only — do not commit
Courses/
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,15 @@ Open http://localhost:3000

```bash
npm test
npm run pack # client + server bundles → dist/ and dist.tar.gz
```

`.github/workflows/release.yml` runs tests, then `npm run pack`: a minified client
bundle, a single-file server bundle (Express + LLM SDKs inlined — no
`node_modules`), and the static files the server serves. Extract `dist.tar.gz`
and run `node server.js`. Supply `session.config.json` and `.env` at runtime.


## Stack

- Node.js + Express
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"start:prod": "node server.js",
"dev": "npm run build && concurrently -k -n client,server -c blue,green \"npm:build:watch\" \"node --watch server.js\"",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"pack": "node scripts/pack-dist.mjs"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.123.0",
Expand Down
154 changes: 154 additions & 0 deletions scripts/pack-dist.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* Builds a runnable release tree into dist/ and archives it as dist.tar.gz.
*
* The tarball is the client bundle + a single-file server bundle + the static
* files Express serves. No node_modules. Extract and `node server.js`.
*
* Usage:
* node scripts/pack-dist.mjs
*/

import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import * as esbuild from 'esbuild';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const DIST = path.join(ROOT, 'dist');
const TAR = path.join(ROOT, 'dist.tar.gz');

function copy(src, dest, filter) {
fs.cpSync(src, dest, { recursive: true, filter });
}

function bytes(file) {
return fs.statSync(file).size;
}

function formatSize(n) {
if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
if (n >= 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${n} B`;
}

function distSize(rel) {
const full = path.join(DIST, rel);
if (!fs.existsSync(full)) return 0;
const st = fs.statSync(full);
if (!st.isDirectory()) return st.size;
let total = 0;
for (const entry of fs.readdirSync(full, { withFileTypes: true })) {
const child = path.join(rel, entry.name);
total += distSize(child);
}
return total;
}

fs.rmSync(DIST, { recursive: true, force: true });
fs.mkdirSync(path.join(DIST, 'public'), { recursive: true });

await esbuild.build({
absWorkingDir: ROOT,
entryPoints: ['public/app.js'],
bundle: true,
outfile: 'dist/public/app.bundle.js',
format: 'esm',
platform: 'browser',
minify: true,
logLevel: 'warning',
});

await esbuild.build({
absWorkingDir: ROOT,
entryPoints: ['server.js'],
bundle: true,
outfile: 'dist/server.js',
format: 'esm',
platform: 'node',
minify: true,
banner: {
js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
},
logLevel: 'warning',
});

copy(path.join(ROOT, 'public/index.html'), path.join(DIST, 'public/index.html'));
copy(path.join(ROOT, 'public/app.css'), path.join(DIST, 'public/app.css'));
copy(path.join(ROOT, 'public/Images'), path.join(DIST, 'public/Images'));
copy(path.join(ROOT, 'design-system'), path.join(DIST, 'design-system'), (src) => {
const rel = path.relative(path.join(ROOT, 'design-system'), src);
return !rel.split(path.sep).includes('.git');
});
// Bundled server resolves import.meta.url to dist/, so keep the prompt beside it.
copy(
path.join(ROOT, 'lib/eval-system-prompt.md'),
path.join(DIST, 'eval-system-prompt.md'),
);
copy(path.join(ROOT, '.env.example'), path.join(DIST, '.env.example'));
copy(
path.join(ROOT, 'session.config.example.json'),
path.join(DIST, 'session.config.example.json'),
);

const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
fs.writeFileSync(
path.join(DIST, 'package.json'),
`${JSON.stringify(
{
name: pkg.name,
version: pkg.version,
private: true,
type: 'module',
scripts: {
start: 'node server.js',
},
},
null,
2,
)}\n`,
);

for (const rel of [
'public/app.bundle.js',
'public/app.css',
'public/index.html',
'server.js',
'eval-system-prompt.md',
]) {
if (!fs.existsSync(path.join(DIST, rel))) throw new Error(`missing ${rel} in dist/`);
}

process.env.NODE_ENV = 'test';
const { app } = await import(pathToFileURL(path.join(DIST, 'server.js')).href);
const server = await new Promise((resolve, reject) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s));
s.on('error', reject);
});
try {
const port = server.address().port;
const health = await fetch(`http://127.0.0.1:${port}/api/session-config`);
if (!health.ok) throw new Error(`session-config check failed (${health.status})`);
await health.json();
} finally {
server.closeAllConnections?.();
await new Promise((resolve) => server.close(resolve));
}

if (fs.existsSync(TAR)) fs.unlinkSync(TAR);
execFileSync('tar', ['-czf', TAR, '-C', DIST, '.'], { cwd: ROOT });

const entries = [
'server.js',
'public/app.bundle.js',
'public',
'design-system',
'eval-system-prompt.md',
];
console.log('Packed dist/ (no node_modules):');
for (const rel of entries) {
console.log(` ${rel.padEnd(24)} ${formatSize(distSize(rel))}`);
}
console.log(` ${'dist.tar.gz'.padEnd(24)} ${formatSize(bytes(TAR))}`);
Loading