9am-build
v1.2.1
Published
CfxLua test runner and build pipeline for FiveM (Cfx.re) resources
Readme
9am-build
Automated build & deploy pipeline for FiveM (Cfx.re) resources. Push to GitHub, get your script on the Cfx.re Portal — with AI-powered changelogs posted to Discord and versioned GitHub releases.
git push --> webhook --> build zip --> upload to portal --> GitHub release --> changelog to DiscordPlus a CfxLua test runner you can use in any resource folder, with no clone and no setup:
cd path/to/your-resource
npx 9am-build test # or: bunx 9am-build testQuick Start
git clone <repo-url> 9am-build && cd 9am-build
bun install
bunx playwright install chromium # one-time browser download
cp .env.example .env # edit with your values
bun run register-passkey # one-time passkey setup
bun run deploy my-resource # build + upload to portal
bun run release my-resource # build + GitHub release only (no portal)Requirements
- Bun v1.0+ — runs the app (CLI, webhook server, portal API calls)
- Node.js v20.11+ — runs the Playwright browser step (Bun cannot drive Playwright's pipe transport, so passkey login/registration runs under Node via a small subprocess). Must be on
PATH, or setNODE_BIN. - Git
- Playwright Chromium — after
bun install, runbunx playwright install chromiumonce (the Docker image does this automatically)
Setup
1. Install Dependencies
bun install2. Configure Environment
cp .env.example .envEdit .env with your values:
WEBHOOK_SECRET=your-webhook-secret
PORT=9000
DISCORD_CHANGELOG_WEBHOOK=https://discord.com/api/webhooks/...
ANTHROPIC_API_KEY=sk-ant-...
GITHUB_TOKEN=ghp_...Generating a strong
WEBHOOK_SECRET:openssl rand -hex 32Use the same value in both
.envand the GitHub webhook secret field.
3. Register a Passkey (One-Time)
The Cfx.re Portal login is automated via a WebAuthn passkey. You register it once, then all future logins are automatic.
Note: This step requires a GUI browser, so do it on your local machine first. Transfer the credential file to your server afterwards.
- Run
bun run register-passkey - A Chromium window opens — log into the Cfx.re Forum if prompted
- It navigates to your security preferences automatically
- Click "Add Passkey", confirm access with your password when prompted, name it (e.g.
9am-build), and confirm - Go back to the terminal and press Enter
- Credentials are saved to
passkey-credential.json
Deploying to a remote server? Copy the credential file:
scp passkey-credential.json user@your-server:/path/to/9am-build/Session cookies are saved to auth-state.json and reused automatically. No GUI needed after initial registration.
4. Add Your Repos
Edit repos.json to register your FiveM resources:
{
"repos": [
{
"name": "my-resource",
"githubUrl": "[email protected]:username/my-resource.git",
"branch": "main"
}
]
}| Field | Description |
|-------|-------------|
| name | Resource name — used for CLI commands and webhook matching |
| githubUrl | Git clone URL (SSH or HTTPS) |
| branch | Branch to track (pushes to other branches are ignored) |
5. Add upload-config.json to Each Resource
Each FiveM resource needs an upload-config.json in its root. This tells 9am-build how to package and where to upload.
{
"name": "my-resource",
"exclude": [
"upload-config.json",
".gitignore",
".git/**",
".vscode/**"
],
"frontend": {
"dir": "web",
"buildCommand": "bun run build",
"buildOutput": "build"
},
"versions": {
"escrow": {
"assetId": 123456,
"escrowIgnore": ["config.lua", "fxmanifest.lua"]
}
}
}| Field | Required | Description |
|-------|----------|-------------|
| name | Yes | Resource name |
| exclude | Yes | Glob patterns to exclude from all zips |
| frontend | No | Frontend build settings |
| frontend.dir | Yes* | Frontend directory (e.g. web) |
| frontend.buildCommand | No | Build command (default: bun run build) |
| frontend.buildOutput | No | Output directory (default: build). Use dist for Vue/Svelte |
| versions | Yes | At least one version must be defined |
| versions.escrow.assetId | Yes* | Cfx.re Portal asset ID |
| versions.escrow.escrowIgnore | Yes* | Files to add to fxmanifest.lua escrow_ignore block |
| versions.open.assetId | Yes* | Cfx.re Portal asset ID |
*Required if parent field is defined.
- Escrow — Source files excluded, only build output included.
escrowIgnorepatterns are injected intofxmanifest.lua. - Open — All files included.
escrow_ignore { "**/*.*", "*" }is added automatically so nothing is encrypted.
You can define one or both versions. Each gets its own zip and asset upload.
- Go to the Cfx.re Portal
- Find your resource in the asset list
- The number in the ID column is your
assetId
Commands
| Command | Description |
|---------|-------------|
| npx 9am-build test | Run *.test.lua in a resource folder — the only published command, needs no clone |
| bun run build <name> | Build zip(s) only — no upload |
| bun run deploy <name> | Build + upload to Cfx.re Portal + GitHub release |
| bun run release <name> | Build + GitHub release only — never touches the portal or opens a browser |
| bun run server | Start webhook server for automated deployments |
| bun run register-passkey | One-time passkey registration |
| bun src/index.ts debug <name> <commit> | Test changelog generation for a commit |
Webhook Mode (CI/CD)
Automate deployments on every push. The server receives GitHub webhooks, builds, uploads, and posts a changelog to Discord.
Setup
Start the server:
bun run serverCreate a webhook on GitHub (Settings > Webhooks > Add webhook):
| Field | Value | |-------|-------| | Payload URL |
https://your-server:9000/webhook| | Content type |application/json| | Secret | Same asWEBHOOK_SECRETin.env| | Events | "Just the push event" |
How It Works
- You push to a tracked branch
- GitHub sends a webhook to your server
- Server verifies the HMAC-SHA256 signature
- Matches the repo/branch against
repos.json - Enqueues the build (one at a time, latest push wins)
- Builds zip(s) and uploads to the Cfx.re Portal
- Generates a changelog via Claude API and posts it to Discord
Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | /health | Health check — returns { status: "ok" } |
| POST | /webhook | GitHub push webhook receiver |
Running with PM2
To keep the server running in the background with auto-restart:
npm install -g pm2
pm2 start bun --name 9am-build -- run server
pm2 save && pm2 startuppm2 logs 9am-build # view logs
pm2 restart 9am-build # restart
pm2 stop 9am-build # stop
pm2 delete 9am-build # removeGitHub Releases
After a successful portal upload, the pipeline creates a GitHub release on the resource's repo, tagged with the version from fxmanifest.lua (e.g. v1.0.3), with auto-generated release notes and the built zips attached as assets (<name>-escrow.zip, <name>-open.zip).
Create a personal access token with
reposcope (classic) orContents: Read and writepermission (fine-grained) for your resource reposAdd it to
.env:GITHUB_TOKEN=ghp_...
- The release targets the exact commit that was built
- If a release for the tag already exists, the zips are attached to it (existing assets with the same name are kept)
- If
GITHUB_TOKENis not set, this step is skipped silently - Failures are non-fatal — the deploy still succeeds if the release fails
Discord Changelog
Automatically posts AI-generated changelogs to Discord after each deployment.
Create a webhook: Channel Settings > Integrations > Webhooks > New Webhook
Add the URL to
.env:DISCORD_CHANGELOG_WEBHOOK=https://discord.com/api/webhooks/...
- Changelogs are generated from commit diffs using OpenRouter (priority) or Anthropic (fallback)
- Written as 1-5 bullet points from the end-user's perspective
- Posted as a gold/yellow embed with the resource name
- Model is configurable via
OPENROUTER_MODEL(default:anthropic/claude-sonnet-4.6) - If
DISCORD_CHANGELOG_WEBHOOKis not set, this step is skipped silently
Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| WEBHOOK_SECRET | Server mode | GitHub webhook HMAC secret |
| PORT | Server mode | HTTP server port (default: 9000) |
| ANTHROPIC_API_KEY | Changelog* | Anthropic API key |
| OPENROUTER_API_KEY | Changelog* | OpenRouter API key (takes priority over Anthropic) |
| OPENROUTER_MODEL | No | OpenRouter model (default: anthropic/claude-sonnet-4.6) |
| DISCORD_CHANGELOG_WEBHOOK | No | Discord webhook URL |
| GITHUB_TOKEN | No | GitHub token for creating releases with build zips |
| CHROMIUM_NO_SANDBOX | No | Set to 1 to disable the Chromium sandbox (needed only when running as root in a container; the Docker image sets it automatically). Leave unset locally. |
*At least one API key required for changelog generation.
CfxLua Unit Tests
Run FiveM resource tests without a live FXServer — powered by the CfxLua CLI runtime (LuaGLM 5.4 + mocked natives, Citizen, events, exports).
# From a resource directory — no clone, no setup
npx 9am-build test # or: bunx 9am-build test
npx 9am-build test --json # machine-readable results
npx 9am-build test --strict # exit 1 when no specs exist
# From a clone of this repo
bun run test:lua ./path/to/my-resource
bunx 9am-build test my-resource # repos.json nameThe published CLI is compiled to plain ESM and runs on Node ≥ 20.11, so npx
works on machines without Bun.
Writing tests
Spec files are discovered at tests/**/*.spec.lua, test/**/*.spec.lua, or any
co-located *.test.lua next to the code it covers (web/ and node_modules/
are skipped). Override the patterns with 9am-test.json:
my-resource/
├── fxmanifest.lua
├── server/
│ └── main.lua
├── tests/
│ └── pricing.spec.lua
└── 9am-test.json # optionalExample spec (tests/pricing.spec.lua):
local pricing = require('server.main')
describe('pricing.withTax', function()
it('adds tax to the base price', function()
expect(pricing.withTax(100, 0.2)).to.equal(120)
end)
end)
describe('events', function()
it('can spy on TriggerEvent', function()
local spy = TestHelpers.spy()
local restore = TestHelpers.mockGlobal('TriggerEvent', spy)
TriggerEvent('shop:open', 1)
restore()
expect(spy:call_count()).to.equal(1)
end)
end)Test API
| Global | Description |
|--------|-------------|
| describe(name, fn) | Group tests |
| it(name, fn) | Define a test case |
| beforeEach(fn) / afterEach(fn) | Per-test hooks |
| expect(value).to.equal(x) | Equality assert |
| expect(value).to.deep_equal(x) | Deep table compare |
| expect(fn).to.throw('msg') | Error assert |
| TestHelpers.spy(fn?) | Callable spy with .calls, :call_count() |
| TestHelpers.mockGlobal(name, value) | Temporarily replace a global |
require('server.pricing') resolves against the resource root through a custom
searcher that loads the module under a resource-relative chunk name, so
tracebacks read server/pricing.lua:4 rather than an absolute path that Lua
truncates into uselessness.
Framework batteries (ox_lib / QBCore / QBox / ESX)
Working fakes for the framework layer load before any resource or spec file,
so a resource written against ox_lib, QBCore, QBox (qbx_core) or ESX
(es_extended) loads with zero configuration: lib.*, QBCore /
exports['qb-core']:GetCoreObject(), exports.qbx_core, and
exports['es_extended']:getSharedObject() all exist and are backed by one
shared player state — money removed through QBCore.Functions.RemoveMoney
is visible through xPlayer.getMoney() and vice versa.
GetResourceState reports exactly one framework as started (default:
qbx_core), so Bridge-style load-time detection behaves as on a real
server, and can be switched per test:
describe('bridge', function()
it('charges through the ESX path', function()
TestHelpers.framework.use('esx')
local Bridge = TestHelpers.reload('server.bridge') -- re-runs detection
TestHelpers.framework.addPlayer(1, { money = { bank = 1000 } })
expect(Bridge.Charge(1, 'bank', 400)).to.be_truthy()
expect(TestHelpers.framework.getState(1).money.bank).to.equal(600)
end)
end)| Helper | Description |
|--------|-------------|
| TestHelpers.framework.use(name) | Activate 'qbox', 'qbcore', 'esx' or 'none' |
| TestHelpers.framework.active() | Currently active framework name |
| TestHelpers.framework.addPlayer(src, opts?) | Seed a player (citizenid, job, money, items all overridable) |
| TestHelpers.framework.removePlayer(src) | Remove a seeded player |
| TestHelpers.framework.getState(src) | Canonical record for assertions |
| TestHelpers.framework.notifications() | Every lib.notify / Notify / showNotification, one log |
| TestHelpers.framework.useItem(src, item) | Trigger a registered useable item |
| TestHelpers.framework.reset() | Clear players + notifications (registries survive) |
| TestHelpers.callback(name, src, ...) | Invoke any registered callback (ox_lib, QBCore or ESX style) and get its results |
| TestHelpers.reload(module) | Drop the require cache and load again |
Callbacks registered via lib.callback.register,
QBCore.Functions.CreateCallback and ESX.RegisterServerCallback land in one
registry; lib.callback.await(name, false, ...) — the client-side call shape —
dispatches there with the default source (TestHelpers.framework.defaultSource()),
so client-flow code exercises real server handlers. Jobs registered through
exports['qb-core']:AddJob, exports.qbx_core:CreateJob or seeded directly are
visible through QBCore.Shared.Jobs, exports.qbx_core:GetJobs() and
ESX.GetJobs() alike.
Reading a failure
Output is plain, greppable, and every location is a path:line anchor — no box
drawing, no status glyphs, and no colour when stdout is not a TTY:
FAIL server/pricing.test.lua:12 withTax > blows up inside resource code
error server/pricing.lua:4: attempt to perform arithmetic on a nil value (local 'price')
traceback
server/pricing.lua:4: in function 'server.pricing.withTax'
server/pricing.test.lua:13: in field 'fn'
source server/pricing.lua:4
3 | function M.withTax(price, rate)
> 4 | return price + (price * rate)
5 | end
24 tests 22 passed 2 failed 26msA failed matcher reports matcher / expected / actual as separate lines
instead of a prose sentence. CfxLua's own bootstrap.lua and scheduler.lua
frames are stripped, since they sit beneath every test and say nothing about the
resource under test. --json emits the same data as one document.
Exit code is 0 when everything passes, 1 on any failure. "No specs found" exits
0 unless you pass --strict.
Add
**/*.test.luaandtests/**toexcludein yourupload-config.jsonso specs never ship inside the escrow or open zip.
Configuration (9am-test.json)
{
"patterns": ["tests/**/*.spec.lua", "tests/**/*.test.lua"],
"include": ["tests/manual.spec.lua"],
"exclude": ["**/node_modules/**"],
"framework": "qbcore", // initial active framework: qbox (default) | qbcore | esx | none
"batteries": true // false disables the framework batteries; or a list, e.g. ["oxlib"]
}Toolchain
On first run, 9am-build downloads CfxLua v1.1.0 to ~/.9am-build/cfxlua/. Override with:
| Variable | Description |
|----------|-------------|
| CFXLUA_VM | Path to cfxlua-vm binary |
| CFXLUA_RUNTIME | Path to cfxlua runtime/ directory |
| CFXLUA_TIMEOUT | Script timeout in ms (default: 30000) |
| NINEAM_CFXLUA_CACHE | Custom cache directory |
On Windows, if the native VM fails to start, 9am-build automatically falls back to the Linux binary via WSL.
Testing the runner itself
bun test covers discovery, config parsing and WSL path translation. The end-to-end
tests spawn the real CfxLua VM; they run automatically once the toolchain is cached
and can be forced with NINEAM_CFXLUA_E2E=1 bun test.
Project Structure
9am-build/
├── src/
│ ├── cli.ts # Published npm entry — `9am-build test` only
│ ├── index.ts # Repo-only CLI entry point & command router
│ ├── cfx/ # Cfx portal layer (browser only for login)
│ │ ├── api.ts # portal-api REST client + chunking/error helpers
│ │ ├── upload.ts # Chunked asset upload + version-cap recovery
│ │ ├── requester.ts # fetch-based Requester (Cookie header from session)
│ │ ├── session.ts # 3-tier ensureSession (jwt → SSO → passkey)
│ │ ├── login.ts # Passkey + SSO-only portal login flows
│ │ ├── passkey.ts # WebAuthn virtual authenticator + credential store
│ │ ├── storage-state.ts # Playwright storageState + legacy migration
│ │ ├── run-browser.ts # Spawns the Node browser runner from Bun
│ │ └── browser-runner.ts # Node entry for login/register (Playwright)
│ ├── core/ # Build & repo primitives
│ │ ├── config.ts # Load & validate upload-config.json
│ │ ├── build.ts # Zip creation & frontend builds
│ │ ├── git.ts # Git clone / pull / diff
│ │ └── manifest.ts # Read version from fxmanifest.lua
│ ├── integrations/ # External services
│ │ ├── github.ts # GitHub release creation & asset upload
│ │ ├── discord.ts # Discord webhook notifications
│ │ └── changelog.ts # AI changelog generation
│ ├── commands/ # One file per CLI command
│ │ ├── test.ts # Run *.test.lua (published)
│ │ ├── build.ts # Zip only
│ │ ├── deploy.ts # Build + portal upload + GitHub release
│ │ ├── release.ts # Build + GitHub release only (no portal)
│ │ ├── register.ts # Passkey registration (headed)
│ │ ├── server.ts # GitHub webhook HTTP server
│ │ ├── test.ts # CfxLua unit test runner
│ │ └── shared.ts # Shared post-release Discord announcement
│ ├── cfxlua/ # Offline FiveM Lua test runner (published)
│ │ ├── discover.ts # Find *.spec.lua / *.test.lua files
│ │ ├── ensure-toolchain.ts # Download/cache CfxLua VM (+ WSL fallback)
│ │ ├── run.ts # Orchestrate execution, parse the JSON payload
│ │ ├── report.ts # Agent-first text and --json rendering
│ │ ├── spawn.ts # node:child_process wrapper (Node-compatible)
│ │ ├── types.ts # Result shapes
│ │ └── test/ # Lua side: framework, helpers, runner
│ │ └── batteries/ # ox_lib / QBCore / QBox / ESX fakes over one shared state
│ └── server-support/
│ ├── queue.ts # Serial build queue (latest-wins)
│ └── repos.ts # repos.json loader
├── repos.json # Managed repo list
├── fixtures/ # Sample resource for CfxLua test demos
├── scripts/copy-lua.mjs # Copy Lua assets into dist on build
├── .env.example # Environment template
└── package.jsonAuto-generated files (gitignored):
| File | Purpose |
|------|---------|
| auth-state.json | Cached Cfx.re session cookies |
| passkey-credential.json | WebAuthn passkey credentials |
| repos/ | Cloned repository working copies |
